Merge branch 'Sora-JSCore'

This commit is contained in:
cranci1 2025-01-11 17:59:47 +01:00
commit 8c1a39cc71
47 changed files with 1600 additions and 3747 deletions

View file

@ -7,307 +7,227 @@
objects = {
/* Begin PBXBuildFile section */
1308CFBC2D19844A004CD38C /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1308CFBB2D19844A004CD38C /* Double+Extension.swift */; };
1308CFBE2D19844D004CD38C /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1308CFBD2D19844D004CD38C /* MusicProgressSlider.swift */; };
1308CFC12D198466004CD38C /* CustomPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1308CFC02D198466004CD38C /* CustomPlayer.swift */; };
132417842D13198000B4F2D2 /* SoraApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417832D13198000B4F2D2 /* SoraApp.swift */; };
132417862D13198000B4F2D2 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417852D13198000B4F2D2 /* ContentView.swift */; };
132417882D13198200B4F2D2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 132417872D13198200B4F2D2 /* Assets.xcassets */; };
1324178B2D13198200B4F2D2 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1324178A2D13198200B4F2D2 /* Preview Assets.xcassets */; };
1324179E2D1319E800B4F2D2 /* MiruDataStruct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417932D1319E800B4F2D2 /* MiruDataStruct.swift */; };
1324179F2D1319E800B4F2D2 /* Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417952D1319E800B4F2D2 /* Notification.swift */; };
132417A02D1319E800B4F2D2 /* HistoryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417972D1319E800B4F2D2 /* HistoryManager.swift */; };
132417A12D1319E800B4F2D2 /* ModuleStruct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417992D1319E800B4F2D2 /* ModuleStruct.swift */; };
132417A22D1319E800B4F2D2 /* ModulesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1324179A2D1319E800B4F2D2 /* ModulesManager.swift */; };
132417A32D1319E800B4F2D2 /* NormalPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1324179C2D1319E800B4F2D2 /* NormalPlayer.swift */; };
132417B82D131A0600B4F2D2 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417A72D131A0600B4F2D2 /* SearchView.swift */; };
132417B92D131A0600B4F2D2 /* SearchResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417A82D131A0600B4F2D2 /* SearchResultsView.swift */; };
132417BA2D131A0600B4F2D2 /* SettingsAboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417AB2D131A0600B4F2D2 /* SettingsAboutView.swift */; };
132417BB2D131A0600B4F2D2 /* SettingsIUView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417AC2D131A0600B4F2D2 /* SettingsIUView.swift */; };
132417BC2D131A0600B4F2D2 /* SettingsLogsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417AD2D131A0600B4F2D2 /* SettingsLogsView.swift */; };
132417BD2D131A0600B4F2D2 /* SettingsModuleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417AE2D131A0600B4F2D2 /* SettingsModuleView.swift */; };
132417BE2D131A0600B4F2D2 /* SettingsPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417AF2D131A0600B4F2D2 /* SettingsPlayerView.swift */; };
132417BF2D131A0600B4F2D2 /* SettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417B02D131A0600B4F2D2 /* SettingView.swift */; };
132417C02D131A0600B4F2D2 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417B12D131A0600B4F2D2 /* HomeView.swift */; };
132417C12D131A0600B4F2D2 /* LibraryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417B32D131A0600B4F2D2 /* LibraryManager.swift */; };
132417C22D131A0600B4F2D2 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417B42D131A0600B4F2D2 /* LibraryView.swift */; };
132417C32D131A0600B4F2D2 /* MediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417B62D131A0600B4F2D2 /* MediaView.swift */; };
132417C42D131A0600B4F2D2 /* MediaExtraction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417B72D131A0600B4F2D2 /* MediaExtraction.swift */; };
132417CF2D131B7400B4F2D2 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 132417CE2D131B7400B4F2D2 /* SwiftSoup */; };
132417D22D131C5300B4F2D2 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 132417D12D131C5300B4F2D2 /* Kingfisher */; };
132417D52D13240200B4F2D2 /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417D42D13240200B4F2D2 /* EpisodeCell.swift */; };
132417D72D13242400B4F2D2 /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417D62D13242400B4F2D2 /* CircularProgressBar.swift */; };
132417D92D1328B900B4F2D2 /* VideoPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132417D82D1328B900B4F2D2 /* VideoPlayerView.swift */; };
1352BA712D1ABC30000A9AF9 /* URLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1352BA702D1ABC30000A9AF9 /* URLSession.swift */; };
13AEE7BA2D2451F200CA634A /* GitHubAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13AEE7B92D2451F200CA634A /* GitHubAPI.swift */; };
13AEE7BC2D24521200CA634A /* SettingsReleasesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13AEE7BB2D24521200CA634A /* SettingsReleasesView.swift */; };
13B3A4B22D1477F100BCC0D5 /* SettingsStorageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13B3A4B12D1477F100BCC0D5 /* SettingsStorageView.swift */; };
13B544B02D26D8E900CC6C59 /* OpenCastSwift iOS in Frameworks */ = {isa = PBXBuildFile; productRef = 13B544AF2D26D8E900CC6C59 /* OpenCastSwift iOS */; };
13C9821F2D2152B1007A0132 /* GitHubRelease.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13C9821E2D2152B1007A0132 /* GitHubRelease.swift */; };
13ED65752D284045008F4C23 /* SettingsEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13ED65742D284045008F4C23 /* SettingsEditorView.swift */; };
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6D2D2BE2500075467E /* SoraApp.swift */; };
133D7C702D2BE2500075467E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6F2D2BE2500075467E /* ContentView.swift */; };
133D7C722D2BE2520075467E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 133D7C712D2BE2520075467E /* Assets.xcassets */; };
133D7C752D2BE2520075467E /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 133D7C742D2BE2520075467E /* Preview Assets.xcassets */; };
133D7C8C2D2BE2640075467E /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C7C2D2BE2630075467E /* SearchView.swift */; };
133D7C8D2D2BE2640075467E /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C7D2D2BE2630075467E /* HomeView.swift */; };
133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C7E2D2BE2630075467E /* LibraryView.swift */; };
133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C802D2BE2630075467E /* MediaInfoView.swift */; };
133D7C902D2BE2640075467E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C822D2BE2630075467E /* SettingsView.swift */; };
133D7C912D2BE2640075467E /* SettingsViewModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C842D2BE2630075467E /* SettingsViewModule.swift */; };
133D7C922D2BE2640075467E /* URLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C872D2BE2640075467E /* URLSession.swift */; };
133D7C932D2BE2640075467E /* Modules.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C892D2BE2640075467E /* Modules.swift */; };
133D7C942D2BE2640075467E /* JSController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C8B2D2BE2640075467E /* JSController.swift */; };
133D7C972D2BE2AF0075467E /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 133D7C962D2BE2AF0075467E /* Kingfisher */; };
138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */; };
138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */; };
13DC0C462D302C7500D0F966 /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DC0C452D302C7500D0F966 /* VideoPlayer.swift */; };
13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */; };
13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */; };
13EA2BD72D32D97400C1EBD7 /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD42D32D97400C1EBD7 /* MusicProgressSlider.swift */; };
13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */; };
13EA2BDC2D32D9FF00C1EBD7 /* MiruDataStruct.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BDB2D32D9FF00C1EBD7 /* MiruDataStruct.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
1308CFBB2D19844A004CD38C /* Double+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = "<group>"; };
1308CFBD2D19844D004CD38C /* MusicProgressSlider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = "<group>"; };
1308CFC02D198466004CD38C /* CustomPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomPlayer.swift; sourceTree = "<group>"; };
132417802D13198000B4F2D2 /* Sora.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sora.app; sourceTree = BUILT_PRODUCTS_DIR; };
132417832D13198000B4F2D2 /* SoraApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoraApp.swift; sourceTree = "<group>"; };
132417852D13198000B4F2D2 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
132417872D13198200B4F2D2 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
1324178A2D13198200B4F2D2 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
132417932D1319E800B4F2D2 /* MiruDataStruct.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MiruDataStruct.swift; sourceTree = "<group>"; };
132417952D1319E800B4F2D2 /* Notification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Notification.swift; sourceTree = "<group>"; };
132417972D1319E800B4F2D2 /* HistoryManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HistoryManager.swift; sourceTree = "<group>"; };
132417992D1319E800B4F2D2 /* ModuleStruct.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ModuleStruct.swift; sourceTree = "<group>"; };
1324179A2D1319E800B4F2D2 /* ModulesManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ModulesManager.swift; sourceTree = "<group>"; };
1324179C2D1319E800B4F2D2 /* NormalPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NormalPlayer.swift; sourceTree = "<group>"; };
132417A72D131A0600B4F2D2 /* SearchView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
132417A82D131A0600B4F2D2 /* SearchResultsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchResultsView.swift; sourceTree = "<group>"; };
132417AB2D131A0600B4F2D2 /* SettingsAboutView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsAboutView.swift; sourceTree = "<group>"; };
132417AC2D131A0600B4F2D2 /* SettingsIUView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsIUView.swift; sourceTree = "<group>"; };
132417AD2D131A0600B4F2D2 /* SettingsLogsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsLogsView.swift; sourceTree = "<group>"; };
132417AE2D131A0600B4F2D2 /* SettingsModuleView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsModuleView.swift; sourceTree = "<group>"; };
132417AF2D131A0600B4F2D2 /* SettingsPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsPlayerView.swift; sourceTree = "<group>"; };
132417B02D131A0600B4F2D2 /* SettingView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingView.swift; sourceTree = "<group>"; };
132417B12D131A0600B4F2D2 /* HomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
132417B32D131A0600B4F2D2 /* LibraryManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryManager.swift; sourceTree = "<group>"; };
132417B42D131A0600B4F2D2 /* LibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
132417B62D131A0600B4F2D2 /* MediaView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaView.swift; sourceTree = "<group>"; };
132417B72D131A0600B4F2D2 /* MediaExtraction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaExtraction.swift; sourceTree = "<group>"; };
132417C52D131AA500B4F2D2 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
132417D42D13240200B4F2D2 /* EpisodeCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodeCell.swift; sourceTree = "<group>"; };
132417D62D13242400B4F2D2 /* CircularProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CircularProgressBar.swift; sourceTree = "<group>"; };
132417D82D1328B900B4F2D2 /* VideoPlayerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoPlayerView.swift; sourceTree = "<group>"; };
1352BA6F2D1AB113000A9AF9 /* Sora.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Sora.entitlements; sourceTree = "<group>"; };
1352BA702D1ABC30000A9AF9 /* URLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLSession.swift; sourceTree = "<group>"; };
13AEE7B92D2451F200CA634A /* GitHubAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubAPI.swift; sourceTree = "<group>"; };
13AEE7BB2D24521200CA634A /* SettingsReleasesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsReleasesView.swift; sourceTree = "<group>"; };
13B3A4B12D1477F100BCC0D5 /* SettingsStorageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStorageView.swift; sourceTree = "<group>"; };
13C9821E2D2152B1007A0132 /* GitHubRelease.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubRelease.swift; sourceTree = "<group>"; };
13ED65742D284045008F4C23 /* SettingsEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsEditorView.swift; sourceTree = "<group>"; };
133D7C6A2D2BE2500075467E /* Sora.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sora.app; sourceTree = BUILT_PRODUCTS_DIR; };
133D7C6D2D2BE2500075467E /* SoraApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoraApp.swift; sourceTree = "<group>"; };
133D7C6F2D2BE2500075467E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
133D7C712D2BE2520075467E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
133D7C742D2BE2520075467E /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = "<group>"; };
133D7C7C2D2BE2630075467E /* SearchView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
133D7C7D2D2BE2630075467E /* HomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
133D7C7E2D2BE2630075467E /* LibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = "<group>"; };
133D7C802D2BE2630075467E /* MediaInfoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MediaInfoView.swift; sourceTree = "<group>"; };
133D7C822D2BE2630075467E /* SettingsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
133D7C842D2BE2630075467E /* SettingsViewModule.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewModule.swift; sourceTree = "<group>"; };
133D7C872D2BE2640075467E /* URLSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLSession.swift; sourceTree = "<group>"; };
133D7C892D2BE2640075467E /* Modules.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Modules.swift; sourceTree = "<group>"; };
133D7C8B2D2BE2640075467E /* JSController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSController.swift; sourceTree = "<group>"; };
138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCell.swift; sourceTree = "<group>"; };
138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressBar.swift; sourceTree = "<group>"; };
13DC0C412D2EC9BA00D0F966 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
13DC0C452D302C7500D0F966 /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = "<group>"; };
13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomPlayer.swift; sourceTree = "<group>"; };
13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = "<group>"; };
13EA2BD42D32D97400C1EBD7 /* MusicProgressSlider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = "<group>"; };
13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NormalPlayer.swift; sourceTree = "<group>"; };
13EA2BDB2D32D9FF00C1EBD7 /* MiruDataStruct.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MiruDataStruct.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
1324177D2D13198000B4F2D2 /* Frameworks */ = {
133D7C672D2BE2500075467E /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
132417D22D131C5300B4F2D2 /* Kingfisher in Frameworks */,
132417CF2D131B7400B4F2D2 /* SwiftSoup in Frameworks */,
13B544B02D26D8E900CC6C59 /* OpenCastSwift iOS in Frameworks */,
133D7C972D2BE2AF0075467E /* Kingfisher in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
1308CFBA2D19843E004CD38C /* CustomPlayer */ = {
133D7C612D2BE2500075467E = {
isa = PBXGroup;
children = (
1308CFC02D198466004CD38C /* CustomPlayer.swift */,
1308CFBF2D198450004CD38C /* Components */,
);
path = CustomPlayer;
sourceTree = "<group>";
};
1308CFBF2D198450004CD38C /* Components */ = {
isa = PBXGroup;
children = (
1308CFBD2D19844D004CD38C /* MusicProgressSlider.swift */,
1308CFBB2D19844A004CD38C /* Double+Extension.swift */,
);
path = Components;
sourceTree = "<group>";
};
132417772D13198000B4F2D2 = {
isa = PBXGroup;
children = (
132417822D13198000B4F2D2 /* Sora */,
132417812D13198000B4F2D2 /* Products */,
133D7C6C2D2BE2500075467E /* Sora */,
133D7C6B2D2BE2500075467E /* Products */,
);
sourceTree = "<group>";
};
132417812D13198000B4F2D2 /* Products */ = {
133D7C6B2D2BE2500075467E /* Products */ = {
isa = PBXGroup;
children = (
132417802D13198000B4F2D2 /* Sora.app */,
133D7C6A2D2BE2500075467E /* Sora.app */,
);
name = Products;
sourceTree = "<group>";
};
132417822D13198000B4F2D2 /* Sora */ = {
133D7C6C2D2BE2500075467E /* Sora */ = {
isa = PBXGroup;
children = (
1352BA6F2D1AB113000A9AF9 /* Sora.entitlements */,
132417C52D131AA500B4F2D2 /* Info.plist */,
132417912D1319E800B4F2D2 /* Utils */,
132417A52D131A0600B4F2D2 /* Views */,
132417832D13198000B4F2D2 /* SoraApp.swift */,
132417852D13198000B4F2D2 /* ContentView.swift */,
132417872D13198200B4F2D2 /* Assets.xcassets */,
132417892D13198200B4F2D2 /* Preview Content */,
13DC0C412D2EC9BA00D0F966 /* Info.plist */,
13DC0C442D302C6A00D0F966 /* MediaPlayer */,
133D7C852D2BE2640075467E /* Utils */,
133D7C7B2D2BE2630075467E /* Views */,
133D7C6D2D2BE2500075467E /* SoraApp.swift */,
133D7C6F2D2BE2500075467E /* ContentView.swift */,
133D7C712D2BE2520075467E /* Assets.xcassets */,
133D7C732D2BE2520075467E /* Preview Content */,
);
path = Sora;
sourceTree = "<group>";
};
132417892D13198200B4F2D2 /* Preview Content */ = {
133D7C732D2BE2520075467E /* Preview Content */ = {
isa = PBXGroup;
children = (
1324178A2D13198200B4F2D2 /* Preview Assets.xcassets */,
133D7C742D2BE2520075467E /* Preview Assets.xcassets */,
);
path = "Preview Content";
sourceTree = "<group>";
};
132417912D1319E800B4F2D2 /* Utils */ = {
133D7C7B2D2BE2630075467E /* Views */ = {
isa = PBXGroup;
children = (
13C9821D2D2152A0007A0132 /* GitHub */,
1308CFBA2D19843E004CD38C /* CustomPlayer */,
132417922D1319E800B4F2D2 /* Miru */,
132417942D1319E800B4F2D2 /* Extensions */,
132417962D1319E800B4F2D2 /* History */,
132417982D1319E800B4F2D2 /* Modules */,
1324179B2D1319E800B4F2D2 /* Player */,
);
path = Utils;
sourceTree = "<group>";
};
132417922D1319E800B4F2D2 /* Miru */ = {
isa = PBXGroup;
children = (
132417932D1319E800B4F2D2 /* MiruDataStruct.swift */,
);
path = Miru;
sourceTree = "<group>";
};
132417942D1319E800B4F2D2 /* Extensions */ = {
isa = PBXGroup;
children = (
132417952D1319E800B4F2D2 /* Notification.swift */,
1352BA702D1ABC30000A9AF9 /* URLSession.swift */,
);
path = Extensions;
sourceTree = "<group>";
};
132417962D1319E800B4F2D2 /* History */ = {
isa = PBXGroup;
children = (
132417972D1319E800B4F2D2 /* HistoryManager.swift */,
);
path = History;
sourceTree = "<group>";
};
132417982D1319E800B4F2D2 /* Modules */ = {
isa = PBXGroup;
children = (
132417992D1319E800B4F2D2 /* ModuleStruct.swift */,
1324179A2D1319E800B4F2D2 /* ModulesManager.swift */,
);
path = Modules;
sourceTree = "<group>";
};
1324179B2D1319E800B4F2D2 /* Player */ = {
isa = PBXGroup;
children = (
132417D82D1328B900B4F2D2 /* VideoPlayerView.swift */,
1324179C2D1319E800B4F2D2 /* NormalPlayer.swift */,
);
path = Player;
sourceTree = "<group>";
};
132417A52D131A0600B4F2D2 /* Views */ = {
isa = PBXGroup;
children = (
132417B12D131A0600B4F2D2 /* HomeView.swift */,
132417B22D131A0600B4F2D2 /* LibraryViews */,
132417A62D131A0600B4F2D2 /* SearchViews */,
132417A92D131A0600B4F2D2 /* SettingsViews */,
132417B52D131A0600B4F2D2 /* MediaViews */,
133D7C832D2BE2630075467E /* SettingsSubViews */,
133D7C7F2D2BE2630075467E /* MediaInfoView */,
133D7C7D2D2BE2630075467E /* HomeView.swift */,
133D7C7E2D2BE2630075467E /* LibraryView.swift */,
133D7C7C2D2BE2630075467E /* SearchView.swift */,
133D7C822D2BE2630075467E /* SettingsView.swift */,
);
path = Views;
sourceTree = "<group>";
};
132417A62D131A0600B4F2D2 /* SearchViews */ = {
133D7C7F2D2BE2630075467E /* MediaInfoView */ = {
isa = PBXGroup;
children = (
132417A72D131A0600B4F2D2 /* SearchView.swift */,
132417A82D131A0600B4F2D2 /* SearchResultsView.swift */,
138AA1B52D2D66EC0021F9DF /* EpisodeCell */,
133D7C802D2BE2630075467E /* MediaInfoView.swift */,
);
path = SearchViews;
path = MediaInfoView;
sourceTree = "<group>";
};
132417A92D131A0600B4F2D2 /* SettingsViews */ = {
133D7C832D2BE2630075467E /* SettingsSubViews */ = {
isa = PBXGroup;
children = (
132417AA2D131A0600B4F2D2 /* SubPages */,
132417B02D131A0600B4F2D2 /* SettingView.swift */,
133D7C842D2BE2630075467E /* SettingsViewModule.swift */,
);
path = SettingsViews;
path = SettingsSubViews;
sourceTree = "<group>";
};
132417AA2D131A0600B4F2D2 /* SubPages */ = {
133D7C852D2BE2640075467E /* Utils */ = {
isa = PBXGroup;
children = (
132417AB2D131A0600B4F2D2 /* SettingsAboutView.swift */,
132417AC2D131A0600B4F2D2 /* SettingsIUView.swift */,
132417AD2D131A0600B4F2D2 /* SettingsLogsView.swift */,
132417AE2D131A0600B4F2D2 /* SettingsModuleView.swift */,
132417AF2D131A0600B4F2D2 /* SettingsPlayerView.swift */,
13B3A4B12D1477F100BCC0D5 /* SettingsStorageView.swift */,
13AEE7BB2D24521200CA634A /* SettingsReleasesView.swift */,
13ED65742D284045008F4C23 /* SettingsEditorView.swift */,
13EA2BDA2D32D9FF00C1EBD7 /* Miru */,
133D7C862D2BE2640075467E /* Extensions */,
133D7C882D2BE2640075467E /* Modules */,
133D7C8A2D2BE2640075467E /* Loaders */,
);
path = SubPages;
path = Utils;
sourceTree = "<group>";
};
132417B22D131A0600B4F2D2 /* LibraryViews */ = {
133D7C862D2BE2640075467E /* Extensions */ = {
isa = PBXGroup;
children = (
132417B42D131A0600B4F2D2 /* LibraryView.swift */,
132417B32D131A0600B4F2D2 /* LibraryManager.swift */,
133D7C872D2BE2640075467E /* URLSession.swift */,
);
path = LibraryViews;
path = Extensions;
sourceTree = "<group>";
};
132417B52D131A0600B4F2D2 /* MediaViews */ = {
133D7C882D2BE2640075467E /* Modules */ = {
isa = PBXGroup;
children = (
132417D32D1323F500B4F2D2 /* EpisodesCell */,
132417B62D131A0600B4F2D2 /* MediaView.swift */,
132417B72D131A0600B4F2D2 /* MediaExtraction.swift */,
133D7C892D2BE2640075467E /* Modules.swift */,
);
path = MediaViews;
path = Modules;
sourceTree = "<group>";
};
132417D32D1323F500B4F2D2 /* EpisodesCell */ = {
133D7C8A2D2BE2640075467E /* Loaders */ = {
isa = PBXGroup;
children = (
132417D42D13240200B4F2D2 /* EpisodeCell.swift */,
132417D62D13242400B4F2D2 /* CircularProgressBar.swift */,
133D7C8B2D2BE2640075467E /* JSController.swift */,
);
path = EpisodesCell;
path = Loaders;
sourceTree = "<group>";
};
13C9821D2D2152A0007A0132 /* GitHub */ = {
138AA1B52D2D66EC0021F9DF /* EpisodeCell */ = {
isa = PBXGroup;
children = (
13C9821E2D2152B1007A0132 /* GitHubRelease.swift */,
13AEE7B92D2451F200CA634A /* GitHubAPI.swift */,
138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */,
138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */,
);
path = GitHub;
path = EpisodeCell;
sourceTree = "<group>";
};
13DC0C442D302C6A00D0F966 /* MediaPlayer */ = {
isa = PBXGroup;
children = (
13EA2BD02D32D97400C1EBD7 /* CustomPlayer */,
13DC0C452D302C7500D0F966 /* VideoPlayer.swift */,
13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */,
);
path = MediaPlayer;
sourceTree = "<group>";
};
13EA2BD02D32D97400C1EBD7 /* CustomPlayer */ = {
isa = PBXGroup;
children = (
13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */,
13EA2BD22D32D97400C1EBD7 /* Components */,
);
path = CustomPlayer;
sourceTree = "<group>";
};
13EA2BD22D32D97400C1EBD7 /* Components */ = {
isa = PBXGroup;
children = (
13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */,
13EA2BD42D32D97400C1EBD7 /* MusicProgressSlider.swift */,
);
path = Components;
sourceTree = "<group>";
};
13EA2BDA2D32D9FF00C1EBD7 /* Miru */ = {
isa = PBXGroup;
children = (
13EA2BDB2D32D9FF00C1EBD7 /* MiruDataStruct.swift */,
);
path = Miru;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
1324177F2D13198000B4F2D2 /* Sora */ = {
133D7C692D2BE2500075467E /* Sora */ = {
isa = PBXNativeTarget;
buildConfigurationList = 1324178E2D13198200B4F2D2 /* Build configuration list for PBXNativeTarget "Sora" */;
buildConfigurationList = 133D7C782D2BE2520075467E /* Build configuration list for PBXNativeTarget "Sora" */;
buildPhases = (
1324177C2D13198000B4F2D2 /* Sources */,
1324177D2D13198000B4F2D2 /* Frameworks */,
1324177E2D13198000B4F2D2 /* Resources */,
133D7C662D2BE2500075467E /* Sources */,
133D7C672D2BE2500075467E /* Frameworks */,
133D7C682D2BE2500075467E /* Resources */,
);
buildRules = (
);
@ -315,30 +235,28 @@
);
name = Sora;
packageProductDependencies = (
132417CE2D131B7400B4F2D2 /* SwiftSoup */,
132417D12D131C5300B4F2D2 /* Kingfisher */,
13B544AF2D26D8E900CC6C59 /* OpenCastSwift iOS */,
133D7C962D2BE2AF0075467E /* Kingfisher */,
);
productName = Sora;
productReference = 132417802D13198000B4F2D2 /* Sora.app */;
productReference = 133D7C6A2D2BE2500075467E /* Sora.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
132417782D13198000B4F2D2 /* Project object */ = {
133D7C622D2BE2500075467E /* Project object */ = {
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = 1;
LastSwiftUpdateCheck = 1320;
LastUpgradeCheck = 1320;
TargetAttributes = {
1324177F2D13198000B4F2D2 = {
133D7C692D2BE2500075467E = {
CreatedOnToolsVersion = 13.2.1;
};
};
};
buildConfigurationList = 1324177B2D13198000B4F2D2 /* Build configuration list for PBXProject "Sora" */;
buildConfigurationList = 133D7C652D2BE2500075467E /* Build configuration list for PBXProject "Sora" */;
compatibilityVersion = "Xcode 13.0";
developmentRegion = en;
hasScannedForEncodings = 0;
@ -346,78 +264,62 @@
en,
Base,
);
mainGroup = 132417772D13198000B4F2D2;
mainGroup = 133D7C612D2BE2500075467E;
packageReferences = (
132417CD2D131B7400B4F2D2 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
132417D02D131C5300B4F2D2 /* XCRemoteSwiftPackageReference "Kingfisher" */,
13B544AE2D26D8E900CC6C59 /* XCRemoteSwiftPackageReference "OpenCastSwift" */,
133D7C952D2BE2AF0075467E /* XCRemoteSwiftPackageReference "Kingfisher" */,
);
productRefGroup = 132417812D13198000B4F2D2 /* Products */;
productRefGroup = 133D7C6B2D2BE2500075467E /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
1324177F2D13198000B4F2D2 /* Sora */,
133D7C692D2BE2500075467E /* Sora */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
1324177E2D13198000B4F2D2 /* Resources */ = {
133D7C682D2BE2500075467E /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
1324178B2D13198200B4F2D2 /* Preview Assets.xcassets in Resources */,
132417882D13198200B4F2D2 /* Assets.xcassets in Resources */,
133D7C752D2BE2520075467E /* Preview Assets.xcassets in Resources */,
133D7C722D2BE2520075467E /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
1324177C2D13198000B4F2D2 /* Sources */ = {
133D7C662D2BE2500075467E /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
13B3A4B22D1477F100BCC0D5 /* SettingsStorageView.swift in Sources */,
132417BB2D131A0600B4F2D2 /* SettingsIUView.swift in Sources */,
132417C42D131A0600B4F2D2 /* MediaExtraction.swift in Sources */,
132417B82D131A0600B4F2D2 /* SearchView.swift in Sources */,
1308CFBC2D19844A004CD38C /* Double+Extension.swift in Sources */,
132417D92D1328B900B4F2D2 /* VideoPlayerView.swift in Sources */,
1324179F2D1319E800B4F2D2 /* Notification.swift in Sources */,
132417BD2D131A0600B4F2D2 /* SettingsModuleView.swift in Sources */,
132417BC2D131A0600B4F2D2 /* SettingsLogsView.swift in Sources */,
1308CFC12D198466004CD38C /* CustomPlayer.swift in Sources */,
132417A22D1319E800B4F2D2 /* ModulesManager.swift in Sources */,
132417862D13198000B4F2D2 /* ContentView.swift in Sources */,
13AEE7BA2D2451F200CA634A /* GitHubAPI.swift in Sources */,
132417C22D131A0600B4F2D2 /* LibraryView.swift in Sources */,
132417A32D1319E800B4F2D2 /* NormalPlayer.swift in Sources */,
132417D72D13242400B4F2D2 /* CircularProgressBar.swift in Sources */,
132417C02D131A0600B4F2D2 /* HomeView.swift in Sources */,
132417BF2D131A0600B4F2D2 /* SettingView.swift in Sources */,
132417C32D131A0600B4F2D2 /* MediaView.swift in Sources */,
132417A12D1319E800B4F2D2 /* ModuleStruct.swift in Sources */,
132417B92D131A0600B4F2D2 /* SearchResultsView.swift in Sources */,
13AEE7BC2D24521200CA634A /* SettingsReleasesView.swift in Sources */,
132417842D13198000B4F2D2 /* SoraApp.swift in Sources */,
132417BE2D131A0600B4F2D2 /* SettingsPlayerView.swift in Sources */,
132417C12D131A0600B4F2D2 /* LibraryManager.swift in Sources */,
132417BA2D131A0600B4F2D2 /* SettingsAboutView.swift in Sources */,
1324179E2D1319E800B4F2D2 /* MiruDataStruct.swift in Sources */,
13ED65752D284045008F4C23 /* SettingsEditorView.swift in Sources */,
1308CFBE2D19844D004CD38C /* MusicProgressSlider.swift in Sources */,
132417D52D13240200B4F2D2 /* EpisodeCell.swift in Sources */,
13C9821F2D2152B1007A0132 /* GitHubRelease.swift in Sources */,
1352BA712D1ABC30000A9AF9 /* URLSession.swift in Sources */,
132417A02D1319E800B4F2D2 /* HistoryManager.swift in Sources */,
13DC0C462D302C7500D0F966 /* VideoPlayer.swift in Sources */,
133D7C902D2BE2640075467E /* SettingsView.swift in Sources */,
13EA2BD72D32D97400C1EBD7 /* MusicProgressSlider.swift in Sources */,
13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */,
133D7C932D2BE2640075467E /* Modules.swift in Sources */,
133D7C702D2BE2500075467E /* ContentView.swift in Sources */,
13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */,
133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */,
133D7C8D2D2BE2640075467E /* HomeView.swift in Sources */,
13EA2BDC2D32D9FF00C1EBD7 /* MiruDataStruct.swift in Sources */,
138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */,
133D7C8C2D2BE2640075467E /* SearchView.swift in Sources */,
133D7C942D2BE2640075467E /* JSController.swift in Sources */,
133D7C922D2BE2640075467E /* URLSession.swift in Sources */,
133D7C912D2BE2640075467E /* SettingsViewModule.swift in Sources */,
133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */,
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */,
138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */,
13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
1324178C2D13198200B4F2D2 /* Debug */ = {
133D7C762D2BE2520075467E /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
@ -478,7 +380,7 @@
};
name = Debug;
};
1324178D2D13198200B4F2D2 /* Release */ = {
133D7C772D2BE2520075467E /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
@ -533,12 +435,11 @@
};
name = Release;
};
1324178F2D13198200B4F2D2 /* Debug */ = {
133D7C792D2BE2520075467E /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Sora/Sora.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Sora/Preview Content\"";
@ -556,22 +457,20 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1.1;
MARKETING_VERSION = 0.2.0;
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.Sora;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
132417902D13198200B4F2D2 /* Release */ = {
133D7C7A2D2BE2520075467E /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Sora/Sora.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_ASSET_PATHS = "\"Sora/Preview Content\"";
@ -589,10 +488,9 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1.1;
MARKETING_VERSION = 0.2.0;
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.Sora;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@ -602,20 +500,20 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
1324177B2D13198000B4F2D2 /* Build configuration list for PBXProject "Sora" */ = {
133D7C652D2BE2500075467E /* Build configuration list for PBXProject "Sora" */ = {
isa = XCConfigurationList;
buildConfigurations = (
1324178C2D13198200B4F2D2 /* Debug */,
1324178D2D13198200B4F2D2 /* Release */,
133D7C762D2BE2520075467E /* Debug */,
133D7C772D2BE2520075467E /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
1324178E2D13198200B4F2D2 /* Build configuration list for PBXNativeTarget "Sora" */ = {
133D7C782D2BE2520075467E /* Build configuration list for PBXNativeTarget "Sora" */ = {
isa = XCConfigurationList;
buildConfigurations = (
1324178F2D13198200B4F2D2 /* Debug */,
132417902D13198200B4F2D2 /* Release */,
133D7C792D2BE2520075467E /* Debug */,
133D7C7A2D2BE2520075467E /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
@ -623,15 +521,7 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
132417CD2D131B7400B4F2D2 /* XCRemoteSwiftPackageReference "SwiftSoup" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/scinfu/SwiftSoup.git";
requirement = {
kind = exactVersion;
version = 2.4.0;
};
};
132417D02D131C5300B4F2D2 /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
133D7C952D2BE2AF0075467E /* XCRemoteSwiftPackageReference "Kingfisher" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/onevcat/Kingfisher.git";
requirement = {
@ -639,33 +529,15 @@
version = 7.9.1;
};
};
13B544AE2D26D8E900CC6C59 /* XCRemoteSwiftPackageReference "OpenCastSwift" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/danwilliams64/OpenCastSwift.git";
requirement = {
branch = master;
kind = branch;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
132417CE2D131B7400B4F2D2 /* SwiftSoup */ = {
133D7C962D2BE2AF0075467E /* Kingfisher */ = {
isa = XCSwiftPackageProductDependency;
package = 132417CD2D131B7400B4F2D2 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
productName = SwiftSoup;
};
132417D12D131C5300B4F2D2 /* Kingfisher */ = {
isa = XCSwiftPackageProductDependency;
package = 132417D02D131C5300B4F2D2 /* XCRemoteSwiftPackageReference "Kingfisher" */;
package = 133D7C952D2BE2AF0075467E /* XCRemoteSwiftPackageReference "Kingfisher" */;
productName = Kingfisher;
};
13B544AF2D26D8E900CC6C59 /* OpenCastSwift iOS */ = {
isa = XCSwiftPackageProductDependency;
package = 13B544AE2D26D8E900CC6C59 /* XCRemoteSwiftPackageReference "OpenCastSwift" */;
productName = "OpenCastSwift iOS";
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 132417782D13198000B4F2D2 /* Project object */;
rootObject = 133D7C622D2BE2500075467E /* Project object */;
}

View file

@ -9,42 +9,6 @@
"revision": "b6f62758f21a8c03cd64f4009c037cfa580a256e",
"version": "7.9.1"
}
},
{
"package": "OpenCastSwift",
"repositoryURL": "https://github.com/danwilliams64/OpenCastSwift.git",
"state": {
"branch": "master",
"revision": "f96bf1ed9c1dcad34a1fcccb50d79a6cf612def4",
"version": null
}
},
{
"package": "SwiftProtobuf",
"repositoryURL": "https://github.com/apple/swift-protobuf",
"state": {
"branch": null,
"revision": "9f0c76544701845ad98716f3f6a774a892152bcb",
"version": "1.26.0"
}
},
{
"package": "SwiftSoup",
"repositoryURL": "https://github.com/scinfu/SwiftSoup.git",
"state": {
"branch": null,
"revision": "5386dab25134eec11fc35fc5e43caf422fad0270",
"version": "2.4.0"
}
},
{
"package": "SwiftyJSON",
"repositoryURL": "https://github.com/SwiftyJSON/SwiftyJSON",
"state": {
"branch": null,
"revision": "af76cf3ef710b6ca5f8c05f3a31307d44a3c5828",
"version": "5.0.2"
}
}
]
},

View file

@ -1,35 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>SchemeUserState</key>
<dict>
<key>Playground (Playground) 1.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>2</integer>
</dict>
<key>Playground (Playground) 2.xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>3</integer>
</dict>
<key>Playground (Playground).xcscheme</key>
<dict>
<key>isShown</key>
<false/>
<key>orderHint</key>
<integer>0</integer>
</dict>
<key>Sora.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
</dict>
</plist>

View file

@ -2,14 +2,13 @@
// ContentView.swift
// Sora
//
// Created by Francesco on 18/12/24.
// Created by Francesco on 06/01/25.
//
import SwiftUI
import Kingfisher
struct ContentView: View {
@EnvironmentObject var modulesManager: ModulesManager
var body: some View {
TabView {
HomeView()
@ -26,93 +25,8 @@ struct ContentView: View {
}
SettingsView()
.tabItem {
Label("Settings", systemImage: "gearshape")
Label("Settings", systemImage: "gear")
}
}
.onAppear {
checkForUpdate()
Logger.shared.log("Started Sora")
}
}
func checkForUpdate() {
fetchLatestRelease { release in
guard let release = release else { return }
let latestVersion = release.tagName
let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.1"
if latestVersion.compare(currentVersion, options: .numeric) == .orderedDescending {
DispatchQueue.main.async {
showUpdateAlert(release: release)
}
}
}
}
func fetchLatestRelease(completion: @escaping (GitHubRelease?) -> Void) {
let url = URL(string: "https://api.github.com/repos/cranci1/Sora/releases/latest")!
URLSession.custom.dataTask(with: url) { data, _, error in
guard let data = data, error == nil else {
completion(nil)
return
}
let release = try? JSONDecoder().decode(GitHubRelease.self, from: data)
completion(release)
}.resume()
}
func showUpdateAlert(release: GitHubRelease) {
let alert = UIAlertController(title: "Update Available", message: "A new version (\(release.tagName)) is available. Would you like to update Sora?", preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "Update", style: .default, handler: { _ in
self.showInstallOptionsAlert(release: release)
}))
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController {
rootViewController.present(alert, animated: true, completion: nil)
}
}
func showInstallOptionsAlert(release: GitHubRelease) {
let installAlert = UIAlertController(title: "Install Update", message: "Choose an installation method:", preferredStyle: .alert)
let downloadUrl = release.assets.first?.browserDownloadUrl ?? ""
installAlert.addAction(UIAlertAction(title: "Install in AltStore", style: .default, handler: { _ in
if let url = URL(string: "altstore://install?url=\(downloadUrl)") {
UIApplication.shared.open(url)
}
}))
installAlert.addAction(UIAlertAction(title: "Install in Sidestore", style: .default, handler: { _ in
if let url = URL(string: "sidestore://install?url=\(downloadUrl)") {
UIApplication.shared.open(url)
}
}))
installAlert.addAction(UIAlertAction(title: "Open in Safari", style: .default, handler: { _ in
if let url = URL(string: downloadUrl) {
UIApplication.shared.open(url)
}
}))
installAlert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController {
rootViewController.present(installAlert, animated: true, completion: nil)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

View file

@ -2,22 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>me.cranci.scheme</string>
<key>CFBundleURLSchemes</key>
<array>
<string>ryu</string>
</array>
</dict>
</array>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
</dict>
</plist>

View file

@ -36,20 +36,20 @@ struct CustomMediaPlayer: View {
@State private var showWatchNextButton = true
@Environment(\.presentationMode) var presentationMode
let module: ModuleStruct
let module: ScrapingModule
let fullUrl: String
let title: String
let episodeNumber: Int
let onWatchNext: () -> Void
init(module: ModuleStruct, urlString: String, fullUrl: String, title: String, episodeNumber: Int, onWatchNext: @escaping () -> Void) {
init(module: ScrapingModule, urlString: String, fullUrl: String, title: String, episodeNumber: Int, onWatchNext: @escaping () -> Void) {
guard let url = URL(string: urlString) else {
fatalError("Invalid URL string")
}
var request = URLRequest(url: url)
if urlString.contains("ascdn") {
request.addValue("\(module.module[0].details.baseURL)", forHTTPHeaderField: "Referer")
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
}
let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]])

View file

@ -1,23 +1,23 @@
//
// VideoPlayerView.swift
// VideoPlayer.swift
// Sora
//
// Created by Francesco on 18/12/24.
// Created by Francesco on 09/01/25.
//
import UIKit
import AVKit
class VideoPlayerViewController: UIViewController {
let module: ModuleStruct
let module: ScrapingModule
var player: AVPlayer?
var playerViewController: AVPlayerViewController?
var playerViewController: NormalPlayer?
var timeObserverToken: Any?
var streamUrl: String?
var fullUrl: String = ""
init(module: ModuleStruct) {
init(module: ScrapingModule) {
self.module = module
super.init(nibName: nil, bundle: nil)
}
@ -35,7 +35,7 @@ class VideoPlayerViewController: UIViewController {
var request = URLRequest(url: url)
if streamUrl.contains("ascdn") {
request.addValue("\(module.module[0].details.baseURL)", forHTTPHeaderField: "Referer")
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
}
let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]])

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View file

@ -2,7 +2,7 @@
// SoraApp.swift
// Sora
//
// Created by Francesco on 18/12/24.
// Created by Francesco on 06/01/25.
//
import SwiftUI
@ -10,39 +10,17 @@ import SwiftUI
@main
struct SoraApp: App {
@StateObject private var settings = Settings()
@StateObject private var modulesManager = ModulesManager()
@StateObject private var moduleManager = ModuleManager()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(moduleManager)
.environmentObject(settings)
.environmentObject(modulesManager)
.accentColor(settings.accentColor)
.onAppear {
settings.updateAppearance()
}
.onOpenURL { url in
handleURL(url)
}
}
}
private func handleURL(_ url: URL) {
guard url.scheme == "sora",
url.host == "module",
let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let moduleURL = components.queryItems?.first(where: { $0.name == "url" })?.value else {
return
}
modulesManager.addModule(from: moduleURL) { result in
switch result {
case .success:
NotificationCenter.default.post(name: .moduleAdded, object: nil)
Logger.shared.log("Successfully added module from URL scheme: \(moduleURL)")
case .failure(let error):
Logger.shared.log("Failed to add module from URL scheme: \(error.localizedDescription)")
}
}
}
}

View file

@ -1,13 +0,0 @@
//
// Notification.swift
// Sora
//
// Created by Francesco on 18/12/24.
//
import Foundation
extension Notification.Name {
static let moduleAdded = Notification.Name("moduleAdded")
static let moduleRemoved = Notification.Name("moduleRemoved")
}

View file

@ -1,17 +1,47 @@
//
// URLSession.swift
// Sora
// Sora-JS
//
// Created by Francesco on 24/12/24.
// Created by Francesco on 05/01/25.
//
import Foundation
extension URLSession {
static let userAgents = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.2365.92",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36 Edg/121.0.2277.128",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_3_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14.3; rv:123.0) Gecko/20100101 Firefox/123.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14.2; rv:122.0) Gecko/20100101 Firefox/122.0",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0",
"Mozilla/5.0 (X11; Linux x86_64; rv:122.0) Gecko/20100101 Firefox/122.0",
"Mozilla/5.0 (Linux; Android 14; SM-S918B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.105 Mobile Safari/537.36",
"Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.105 Mobile Safari/537.36",
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1",
"Mozilla/5.0 (iPad; CPU OS 17_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1",
"Mozilla/5.0 (Android 14; Mobile; rv:123.0) Gecko/123.0 Firefox/123.0",
"Mozilla/5.0 (Android 13; Mobile; rv:122.0) Gecko/122.0 Firefox/122.0"
]
static let randomUserAgent: String = {
userAgents.randomElement() ?? userAgents[0]
}()
static let custom: URLSession = {
let configuration = URLSessionConfiguration.default
configuration.httpAdditionalHeaders = [
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
"User-Agent": randomUserAgent
]
return URLSession(configuration: configuration)
}()

View file

@ -1,38 +0,0 @@
//
// GitHubAPI.swift
// Sora
//
// Created by Francesco on 31/12/24.
//
import Foundation
struct GitHubReleases: Codable {
let tagName: String
let body: String
let htmlUrl: String
enum CodingKeys: String, CodingKey {
case tagName = "tag_name"
case body
case htmlUrl = "html_url"
}
}
class GitHubAPI {
static let shared = GitHubAPI()
func fetchReleases(completion: @escaping ([GitHubReleases]?) -> Void) {
let url = URL(string: "https://api.github.com/repos/cranci1/Sora/releases")!
URLSession.custom.dataTask(with: url) { data, _, error in
guard let data = data, error == nil else {
completion(nil)
return
}
let releases = try? JSONDecoder().decode([GitHubReleases].self, from: data)
completion(releases)
}.resume()
}
}

View file

@ -1,124 +0,0 @@
//
// GitHubRelease.swift
// Sora
//
// Created by Francesco on 29/12/24.
//
import Foundation
struct GitHubRelease: Codable {
let url: String
let assetsUrl: String
let uploadUrl: String
let htmlUrl: String
let id: Int
let author: Author
let nodeId: String
let tagName: String
let targetCommitish: String
let name: String
let draft: Bool
let prerelease: Bool
let createdAt: String
let publishedAt: String
let assets: [Asset]
let tarballUrl: String
let zipballUrl: String
let body: String
enum CodingKeys: String, CodingKey {
case url
case assetsUrl = "assets_url"
case uploadUrl = "upload_url"
case htmlUrl = "html_url"
case id
case author
case nodeId = "node_id"
case tagName = "tag_name"
case targetCommitish = "target_commitish"
case name
case draft
case prerelease
case createdAt = "created_at"
case publishedAt = "published_at"
case assets
case tarballUrl = "tarball_url"
case zipballUrl = "zipball_url"
case body
}
struct Author: Codable {
let login: String
let id: Int
let nodeId: String
let avatarUrl: String
let gravatarId: String
let url: String
let htmlUrl: String
let followersUrl: String
let followingUrl: String
let gistsUrl: String
let starredUrl: String
let subscriptionsUrl: String
let organizationsUrl: String
let reposUrl: String
let eventsUrl: String
let receivedEventsUrl: String
let type: String
let siteAdmin: Bool
enum CodingKeys: String, CodingKey {
case login
case id
case nodeId = "node_id"
case avatarUrl = "avatar_url"
case gravatarId = "gravatar_id"
case url
case htmlUrl = "html_url"
case followersUrl = "followers_url"
case followingUrl = "following_url"
case gistsUrl = "gists_url"
case starredUrl = "starred_url"
case subscriptionsUrl = "subscriptions_url"
case organizationsUrl = "organizations_url"
case reposUrl = "repos_url"
case eventsUrl = "events_url"
case receivedEventsUrl = "received_events_url"
case type
case siteAdmin = "site_admin"
}
}
struct Asset: Codable {
let url: String
let id: Int
let nodeId: String
let name: String
let label: String?
let uploader: Author
let contentType: String
let state: String
let size: Int
let downloadCount: Int
let createdAt: String
let updatedAt: String
let browserDownloadUrl: String
enum CodingKeys: String, CodingKey {
case url
case id
case nodeId = "node_id"
case name
case label
case uploader
case contentType = "content_type"
case state
case size
case downloadCount = "download_count"
case createdAt = "created_at"
case updatedAt = "updated_at"
case browserDownloadUrl = "browser_download_url"
}
}
}

View file

@ -1,37 +0,0 @@
//
// HistoryManager.swift
// Sora
//
// Created by Francesco on 18/12/24.
//
import Foundation
import Combine
class HistoryManager: ObservableObject {
@Published var searchHistory: [String] = UserDefaults.standard.stringArray(forKey: "SearchHistory") ?? []
private var cancellables = Set<AnyCancellable>()
init() {
NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)
.sink { [weak self] _ in
DispatchQueue.main.async {
self?.searchHistory = UserDefaults.standard.stringArray(forKey: "SearchHistory") ?? []
}
}
.store(in: &cancellables)
}
func addSearchHistory(_ item: String) {
if !searchHistory.contains(item) {
searchHistory.insert(item, at: 0)
UserDefaults.standard.set(searchHistory, forKey: "SearchHistory")
}
}
func deleteHistoryItem(at offsets: IndexSet) {
searchHistory.remove(atOffsets: offsets)
UserDefaults.standard.set(searchHistory, forKey: "SearchHistory")
}
}

View file

@ -0,0 +1,404 @@
//
// JSController.swift
// Sora
//
// Created by Francesco on 05/01/25.
//
import JavaScriptCore
class JSController: ObservableObject {
private var context: JSContext
init() {
self.context = JSContext()
setupContext()
}
private func setupContext() {
let logFunction: @convention(block) (String) -> Void = { message in
print("JavaScript log: \(message)")
}
context.setObject(logFunction, forKeyedSubscript: "log" as NSString)
let fetchNativeFunction: @convention(block) (String, JSValue, JSValue) -> Void = { urlString, resolve, reject in
guard let url = URL(string: urlString) else {
print("Invalid URL")
reject.call(withArguments: ["Invalid URL"])
return
}
let task = URLSession.custom.dataTask(with: url) { data, _, error in
if let error = error {
print(url)
print("Network error in fetchNativeFunction: \(error.localizedDescription)")
reject.call(withArguments: [error.localizedDescription])
return
}
guard let data = data else {
print("No data in response")
reject.call(withArguments: ["No data"])
return
}
if let text = String(data: data, encoding: .utf8) {
resolve.call(withArguments: [text])
} else {
print("Unable to decode data to text")
reject.call(withArguments: ["Unable to decode data"])
}
}
task.resume()
}
context.setObject(fetchNativeFunction, forKeyedSubscript: "fetchNative" as NSString)
let fetchDefinition = """
function fetch(url) {
return new Promise(function(resolve, reject) {
fetchNative(url, resolve, reject);
});
}
"""
context.evaluateScript(fetchDefinition)
}
func loadScript(_ script: String) {
context = JSContext()
setupContext()
context.evaluateScript(script)
}
func fetchSearchResults(keyword: String, module: ScrapingModule, completion: @escaping ([SearchItem]) -> Void) {
let searchUrl = module.metadata.searchBaseUrl.replacingOccurrences(of: "%s", with: keyword.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")
guard let url = URL(string: searchUrl) else {
completion([])
return
}
URLSession.custom.dataTask(with: url) { [weak self] data, _, error in
guard let self = self else { return }
if let error = error {
print("Network error: \(error)")
DispatchQueue.main.async { completion([]) }
return
}
guard let data = data, let html = String(data: data, encoding: .utf8) else {
print("Failed to decode HTML")
DispatchQueue.main.async { completion([]) }
return
}
if let parseFunction = self.context.objectForKeyedSubscript("searchResults"),
let results = parseFunction.call(withArguments: [html]).toArray() as? [[String: String]] {
let resultItems = results.map { item in
SearchItem(
title: item["title"] ?? "",
imageUrl: item["image"] ?? "",
href: item["href"] ?? ""
)
}
DispatchQueue.main.async {
completion(resultItems)
}
} else {
print("Failed to parse results")
DispatchQueue.main.async { completion([]) }
}
}.resume()
}
func fetchDetails(url: String, completion: @escaping ([MediaItem], [EpisodeLink]) -> Void) {
guard let url = URL(string: url) else {
completion([], [])
return
}
URLSession.custom.dataTask(with: url) { [weak self] data, _, error in
guard let self = self else { return }
if let error = error {
print("Network error: \(error)")
DispatchQueue.main.async { completion([], []) }
return
}
guard let data = data, let html = String(data: data, encoding: .utf8) else {
print("Failed to decode HTML")
DispatchQueue.main.async { completion([], []) }
return
}
var resultItems: [MediaItem] = []
var episodeLinks: [EpisodeLink] = []
if let parseFunction = self.context.objectForKeyedSubscript("extractDetails"),
let results = parseFunction.call(withArguments: [html]).toArray() as? [[String: String]] {
resultItems = results.map { item in
MediaItem(
description: item["description"] ?? "",
aliases: item["aliases"] ?? "",
airdate: item["airdate"] ?? ""
)
}
} else {
print("Failed to parse results")
}
if let fetchEpisodesFunction = self.context.objectForKeyedSubscript("extractEpisodes"),
let episodesResult = fetchEpisodesFunction.call(withArguments: [html]).toArray() as? [[String: String]] {
for episodeData in episodesResult {
if let num = episodeData["number"], let link = episodeData["href"], let number = Int(num) {
episodeLinks.append(EpisodeLink(number: number, href: link))
}
}
}
DispatchQueue.main.async {
completion(resultItems, episodeLinks)
}
}.resume()
}
func fetchStreamUrl(episodeUrl: String, completion: @escaping (String?) -> Void) {
guard let url = URL(string: episodeUrl) else {
completion(nil)
return
}
URLSession.custom.dataTask(with: url) { [weak self] data, _, error in
guard let self = self else { return }
if let error = error {
print("Network error: \(error)")
DispatchQueue.main.async { completion(nil) }
return
}
guard let data = data, let html = String(data: data, encoding: .utf8) else {
print("Failed to decode HTML")
DispatchQueue.main.async { completion(nil) }
return
}
if let parseFunction = self.context.objectForKeyedSubscript("extractStreamUrl"),
let streamUrl = parseFunction.call(withArguments: [html]).toString() {
DispatchQueue.main.async {
completion(streamUrl)
}
} else {
print("Failed to extract stream URL")
DispatchQueue.main.async { completion(nil) }
}
}.resume()
}
func fetchJsSearchResults(keyword: String, module: ScrapingModule, completion: @escaping ([SearchItem]) -> Void) {
if let exception = context.exception {
print("JavaScript exception: \(exception)")
completion([])
return
}
guard let searchResultsFunction = context.objectForKeyedSubscript("searchResults") else {
print("No JavaScript function searchResults found")
completion([])
return
}
let promiseValue = searchResultsFunction.call(withArguments: [keyword])
guard let promise = promiseValue else {
print("searchResults did not return a Promise")
completion([])
return
}
let thenBlock: @convention(block) (JSValue) -> Void = { result in
if let jsonString = result.toString(),
let data = jsonString.data(using: .utf8) {
do {
if let array = try JSONSerialization.jsonObject(with: data, options: []) as? [[String: Any]] {
let resultItems = array.map { item -> SearchItem in
let title = item["title"] as? String ?? ""
let imageUrl = item["image"] as? String ?? "https://s4.anilist.co/file/anilistcdn/character/large/default.jpg"
let href = item["href"] as? String ?? ""
return SearchItem(title: title, imageUrl: imageUrl, href: href)
}
DispatchQueue.main.async {
completion(resultItems)
}
} else {
print("Failed to parse JSON")
DispatchQueue.main.async {
completion([])
}
}
} catch {
print("JSON parsing error: \(error)")
DispatchQueue.main.async {
completion([])
}
}
} else {
print("Result is not a string")
DispatchQueue.main.async {
completion([])
}
}
}
let catchBlock: @convention(block) (JSValue) -> Void = { error in
print("Promise rejected: \(String(describing: error.toString()))")
DispatchQueue.main.async {
completion([])
}
}
let thenFunction = JSValue(object: thenBlock, in: context)
let catchFunction = JSValue(object: catchBlock, in: context)
promise.invokeMethod("then", withArguments: [thenFunction as Any])
promise.invokeMethod("catch", withArguments: [catchFunction as Any])
}
func fetchDetailsJS(url: String, completion: @escaping ([MediaItem], [EpisodeLink]) -> Void) {
guard let url = URL(string: url) else {
completion([], [])
return
}
if let exception = context.exception {
print("JavaScript exception: \(exception)")
completion([], [])
return
}
guard let extractDetailsFunction = context.objectForKeyedSubscript("extractDetails") else {
print("No JavaScript function extractDetails found")
completion([], [])
return
}
guard let extractEpisodesFunction = context.objectForKeyedSubscript("extractEpisodes") else {
print("No JavaScript function extractEpisodes found")
completion([], [])
return
}
var resultItems: [MediaItem] = []
var episodeLinks: [EpisodeLink] = []
let promiseValueDetails = extractDetailsFunction.call(withArguments: [url.absoluteString])
guard let promiseDetails = promiseValueDetails else {
print("extractDetails did not return a Promise")
completion([], [])
return
}
let thenBlockDetails: @convention(block) (JSValue) -> Void = { result in
if let jsonOfDetails = result.toString(),
let dataDetails = jsonOfDetails.data(using: .utf8) {
do {
if let array = try JSONSerialization.jsonObject(with: dataDetails, options: []) as? [[String: Any]] {
resultItems = array.map { item -> MediaItem in
let description = item["description"] as? String ?? ""
let aliases = item["aliases"] as? String ?? ""
let airdate = item["airdate"] as? String ?? ""
return MediaItem(description: description, aliases: aliases, airdate: airdate)
}
} else {
print("Failed to parse JSON of extractDetails")
DispatchQueue.main.async {
completion([], [])
}
}
} catch {
print("JSON parsing error of extract details: \(error)")
DispatchQueue.main.async {
completion([], [])
}
}
} else {
print("Result is not a string of extractDetails")
DispatchQueue.main.async {
completion([], [])
}
}
}
let catchBlockDetails: @convention(block) (JSValue) -> Void = { error in
print("Promise rejected of extractDetails: \(String(describing: error.toString()))")
DispatchQueue.main.async {
completion([], [])
}
}
let thenFunctionDetails = JSValue(object: thenBlockDetails, in: context)
let catchFunctionDetails = JSValue(object: catchBlockDetails, in: context)
promiseDetails.invokeMethod("then", withArguments: [thenFunctionDetails as Any])
promiseDetails.invokeMethod("catch", withArguments: [catchFunctionDetails as Any])
let promiseValueEpisodes = extractEpisodesFunction.call(withArguments: [url.absoluteString])
guard let promiseEpisodes = promiseValueEpisodes else {
print("extractEpisodes did not return a Promise")
completion([], [])
return
}
let thenBlockEpisodes: @convention(block) (JSValue) -> Void = { result in
if let jsonOfEpisodes = result.toString(),
let dataEpisodes = jsonOfEpisodes.data(using: .utf8) {
do {
if let array = try JSONSerialization.jsonObject(with: dataEpisodes, options: []) as? [[String: Any]] {
episodeLinks = array.map { item -> EpisodeLink in
let number = item["number"] as? Int ?? 0
let href = item["href"] as? String ?? ""
return EpisodeLink(number: number, href: href)
}
DispatchQueue.main.async {
completion(resultItems, episodeLinks)
}
} else {
print("Failed to parse JSON of extractEpisodes")
DispatchQueue.main.async {
completion([], [])
}
}
} catch {
print("JSON parsing error of extractEpisodes: \(error)")
DispatchQueue.main.async {
completion([], [])
}
}
} else {
print("Result is not a string of extractEpisodes")
DispatchQueue.main.async {
completion([], [])
}
}
}
let catchBlockEpisodes: @convention(block) (JSValue) -> Void = { error in
print("Promise rejected of extractEpisodes: \(String(describing: error.toString()))")
DispatchQueue.main.async {
completion([], [])
}
}
let thenFunctionEpisodes = JSValue(object: thenBlockEpisodes, in: context)
let catchFunctionEpisodes = JSValue(object: catchBlockEpisodes, in: context)
promiseEpisodes.invokeMethod("then", withArguments: [thenFunctionEpisodes as Any])
promiseEpisodes.invokeMethod("catch", withArguments: [catchFunctionEpisodes as Any])
}
}

View file

@ -1,80 +0,0 @@
//
// ModuleStruct.swift
// Sora
//
// Created by Francesco on 18/12/24.
//
import Foundation
struct ModuleStruct: Codable {
let name: String
let version: String
let author: Author
let iconURL: String
let stream: String
let language: String
let extractor: String
let module: [Module]
struct Author: Codable {
let name: String
let website: String
}
struct Module: Codable, Hashable {
let search: Search
let featured: Featured
let details: Details
let episodes: Episodes
struct Search: Codable, Hashable {
let url: String
let parameter: String
let documentSelector: String
let title: String
let image: Image
let href: String
let searchable: Bool?
struct Image: Codable, Hashable {
let url: String
let attribute: String
}
}
struct Featured: Codable, Hashable {
let url: String
let documentSelector: String
let title: String
let image: Image
let href: String
struct Image: Codable, Hashable {
let url: String
let attribute: String
}
}
struct Details: Codable, Hashable {
let baseURL: String
let pageRedirects: Bool?
let aliases: Aliases
let synopsis: String
let airdate: String
let stars: String
struct Aliases: Codable, Hashable {
let selector: String
let attribute: String
}
}
struct Episodes: Codable, Hashable {
let selector: String
let order: String
let pattern: String
let pattern2: String?
}
}
}

View file

@ -0,0 +1,120 @@
//
// Modules.swift
// Sora
//
// Created by Francesco on 05/01/25.
//
import Foundation
struct ModuleMetadata: Codable, Hashable {
let sourceName: String
let author: String
let iconUrl: String
let version: String
let language: String
let baseUrl: String
let searchBaseUrl: String
let scriptUrl: String
let asyncJS: Bool?
}
struct ScrapingModule: Codable, Identifiable, Hashable {
let id: UUID
let metadata: ModuleMetadata
let localPath: String
var isActive: Bool
init(id: UUID = UUID(), metadata: ModuleMetadata, localPath: String, isActive: Bool = false) {
self.id = id
self.metadata = metadata
self.localPath = localPath
self.isActive = isActive
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: ScrapingModule, rhs: ScrapingModule) -> Bool {
lhs.id == rhs.id
}
}
class ModuleManager: ObservableObject {
@Published var modules: [ScrapingModule] = []
private let fileManager = FileManager.default
private let modulesFileName = "modules.json"
init() {
loadModules()
}
private func getDocumentsDirectory() -> URL {
fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
}
private func getModulesFilePath() -> URL {
getDocumentsDirectory().appendingPathComponent(modulesFileName)
}
func loadModules() {
let url = getModulesFilePath()
guard let data = try? Data(contentsOf: url) else { return }
modules = (try? JSONDecoder().decode([ScrapingModule].self, from: data)) ?? []
}
private func saveModules() {
let url = getModulesFilePath()
guard let data = try? JSONEncoder().encode(modules) else { return }
try? data.write(to: url)
}
func addModule(metadataUrl: String) async throws -> ScrapingModule {
guard let url = URL(string: metadataUrl) else {
throw NSError(domain: "Invalid metadata URL", code: -1)
}
let (metadataData, _) = try await URLSession.custom.data(from: url)
let metadata = try JSONDecoder().decode(ModuleMetadata.self, from: metadataData)
guard let scriptUrl = URL(string: metadata.scriptUrl) else {
throw NSError(domain: "Invalid script URL", code: -1)
}
let (scriptData, _) = try await URLSession.custom.data(from: scriptUrl)
guard let jsContent = String(data: scriptData, encoding: .utf8) else {
throw NSError(domain: "Invalid script encoding", code: -1)
}
let fileName = "\(UUID().uuidString).js"
let localUrl = getDocumentsDirectory().appendingPathComponent(fileName)
try jsContent.write(to: localUrl, atomically: true, encoding: .utf8)
let module = ScrapingModule(
metadata: metadata,
localPath: fileName
)
DispatchQueue.main.async {
self.modules.append(module)
self.saveModules()
}
return module
}
func deleteModule(_ module: ScrapingModule) {
let localUrl = getDocumentsDirectory().appendingPathComponent(module.localPath)
try? fileManager.removeItem(at: localUrl)
modules.removeAll { $0.id == module.id }
saveModules()
}
func getModuleContent(_ module: ScrapingModule) throws -> String {
let localUrl = getDocumentsDirectory().appendingPathComponent(module.localPath)
return try String(contentsOf: localUrl, encoding: .utf8)
}
}

View file

@ -1,155 +0,0 @@
//
// ModulesManager.swift
// Sora
//
// Created by Francesco on 18/12/24.
//
import Foundation
class ModulesManager: ObservableObject {
@Published var modules: [ModuleStruct] = []
@Published var isLoading = true
var moduleURLs: [String: String] = [:]
private let modulesFileName = "modules.json"
private let moduleURLsFileName = "moduleURLs.json"
init() {
loadModules()
}
func loadModules() {
isLoading = true
loadModuleURLs()
loadModuleData()
isLoading = false
}
func addModule(from urlString: String, completion: @escaping (Result<Void, Error>) -> Void) {
guard let url = URL(string: urlString) else {
completion(.failure(ModuleError.invalidURL))
return
}
let task = URLSession.custom.dataTask(with: url) { data, _, error in
guard let data = data, error == nil else {
completion(.failure(error ?? ModuleError.unknown))
return
}
do {
let module = try JSONDecoder().decode(ModuleStruct.self, from: data)
DispatchQueue.main.async {
if !self.modules.contains(where: { $0.name == module.name }) {
self.modules.append(module)
self.moduleURLs[module.name] = urlString
self.saveModuleData()
self.saveModuleURLs()
NotificationCenter.default.post(name: .moduleAdded, object: nil)
completion(.success(()))
} else {
completion(.failure(ModuleError.duplicateModule))
}
}
} catch {
completion(.failure(error))
}
}
task.resume()
}
func deleteModule(named name: String) {
if let index = modules.firstIndex(where: { $0.name == name }) {
modules.remove(at: index)
moduleURLs.removeValue(forKey: name)
saveModuleData()
saveModuleURLs()
NotificationCenter.default.post(name: .moduleRemoved, object: nil)
}
}
func refreshModules() {
for (name, urlString) in moduleURLs {
guard let url = URL(string: urlString) else { continue }
let task = URLSession.custom.dataTask(with: url) { data, _, error in
guard let data = data, error == nil else { return }
do {
let updatedModule = try JSONDecoder().decode(ModuleStruct.self, from: data)
DispatchQueue.main.async {
if let index = self.modules.firstIndex(where: { $0.name == name }) {
self.modules[index] = updatedModule
self.saveModuleData()
}
}
} catch {
print("Failed to decode module during refresh: \(error.localizedDescription)")
Logger.shared.log("Failed to decode module during refresh: \(error.localizedDescription)")
}
}
task.resume()
}
}
private func loadModuleURLs() {
let fileURL = getDocumentsDirectory().appendingPathComponent(moduleURLsFileName)
do {
let data = try Data(contentsOf: fileURL)
moduleURLs = try JSONDecoder().decode([String: String].self, from: data)
} catch {
print("Failed to load module URLs: \(error.localizedDescription)")
Logger.shared.log("Failed to load module URLs: \(error.localizedDescription)")
}
}
private func loadModuleData() {
let fileURL = getDocumentsDirectory().appendingPathComponent(modulesFileName)
do {
let data = try Data(contentsOf: fileURL)
modules = try JSONDecoder().decode([ModuleStruct].self, from: data)
} catch {
print("Failed to load modules: \(error.localizedDescription)")
Logger.shared.log("Failed to load modules: \(error.localizedDescription)")
}
}
func saveModuleData() {
let fileURL = getDocumentsDirectory().appendingPathComponent(modulesFileName)
do {
let data = try JSONEncoder().encode(modules)
try data.write(to: fileURL)
} catch {
print("Failed to save modules: \(error.localizedDescription)")
Logger.shared.log("Failed to save modules: \(error.localizedDescription)")
}
}
private func saveModuleURLs() {
let fileURL = getDocumentsDirectory().appendingPathComponent(moduleURLsFileName)
do {
let data = try JSONEncoder().encode(moduleURLs)
try data.write(to: fileURL)
} catch {
print("Failed to save module URLs: \(error.localizedDescription)")
Logger.shared.log("Failed to save module URLs: \(error.localizedDescription)")
}
}
private func getDocumentsDirectory() -> URL {
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
}
enum ModuleError: LocalizedError {
case invalidURL
case duplicateModule
case unknown
var errorDescription: String? {
switch self {
case .invalidURL:
return "The provided URL is invalid."
case .duplicateModule:
return "This module already exists."
case .unknown:
return "An unknown error occurred."
}
}
}
}

View file

@ -2,145 +2,15 @@
// HomeView.swift
// Sora
//
// Created by Francesco on 18/12/24.
// Created by Francesco on 05/01/25.
//
import SwiftUI
import Kingfisher
import SwiftSoup
struct HomeView: View {
@StateObject private var modulesManager = ModulesManager()
@State private var featuredItems: [String: [ItemResult]] = [:]
@State private var isLoading = true
var body: some View {
NavigationView {
ScrollView {
VStack(alignment: .leading) {
if isLoading {
ProgressView("Loading Featured Items...")
.padding()
} else {
ForEach(modulesManager.modules, id: \.name) { module in
if let items = featuredItems[module.name], !items.isEmpty {
VStack(alignment: .leading) {
HStack(alignment: .bottom) {
Text("Featured")
.font(.title2)
.bold()
.padding(.leading)
Text("on \(module.name)")
.font(.system(size: 15))
.foregroundColor(.secondary)
.bold()
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 20) {
ForEach(items) { item in
NavigationLink(destination: MediaView(module: module, item: item)) {
VStack {
KFImage(URL(string: item.imageUrl))
.resizable()
.scaledToFill()
.frame(width: 120, height: 180)
.clipped()
.cornerRadius(8)
Text(item.name)
.font(.caption)
.lineLimit(1)
.foregroundColor(.primary)
}
.frame(width: 120)
.padding(.leading, 5)
}
}
}
.padding(.horizontal)
}
}
.padding(.bottom)
}
}
}
}
.navigationTitle("Home")
}
.onAppear {
if featuredItems.isEmpty {
fetchFeaturedItems()
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
private func fetchFeaturedItems() {
isLoading = true
let group = DispatchGroup()
for module in modulesManager.modules {
group.enter()
fetchFeaturedItems(for: module) { items in
DispatchQueue.main.async {
featuredItems[module.name] = items
group.leave()
}
}
}
group.notify(queue: .main) {
isLoading = false
}
}
private func fetchFeaturedItems(for module: ModuleStruct, completion: @escaping ([ItemResult]) -> Void) {
let urlString = module.module[0].featured.url
guard let url = URL(string: urlString) else {
completion([])
return
}
URLSession.custom.dataTask(with: url) { data, _, error in
guard let data = data, error == nil else {
completion([])
return
}
do {
let html = String(data: data, encoding: .utf8) ?? ""
let document = try SwiftSoup.parse(html)
let elements = try document.select(module.module[0].featured.documentSelector)
var results: [ItemResult] = []
for element in elements {
let title = try element.select(module.module[0].featured.title).text()
let href = try element.select(module.module[0].featured.href).attr("href")
var imageURL = try element.select(module.module[0].featured.image.url).attr(module.module[0].featured.image.attribute)
if imageURL.contains(",") {
imageURL = imageURL.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.first ?? imageURL
}
if !imageURL.starts(with: "http") {
imageURL = "\(module.module[0].details.baseURL.hasSuffix("/") ? module.module[0].details.baseURL : "\(module.module[0].details.baseURL)/")\(imageURL.hasPrefix("/") ? String(imageURL.dropFirst()) : imageURL)"
}
imageURL = imageURL.replacingOccurrences(of: " ", with: "%20")
let result = ItemResult(name: title, imageUrl: imageURL, href: href)
results.append(result)
}
completion(results)
} catch {
print("Error parsing HTML: \(error)")
Logger.shared.log("Error parsing HTML: \(error)")
completion([])
}
}.resume()
Text("Home View")
.font(.largeTitle)
.padding()
}
}

View file

@ -0,0 +1,16 @@
//
// LibraryView.swift
// Sora
//
// Created by Francesco on 05/01/25.
//
import SwiftUI
struct LibraryView: View {
var body: some View {
Text("Library View")
.font(.largeTitle)
.padding()
}
}

View file

@ -1,70 +0,0 @@
//
// LibraryManager.swift
// Sora
//
// Created by Francesco on 18/12/24.
//
import Foundation
class LibraryManager: ObservableObject {
static let shared = LibraryManager()
@Published var libraryItems: [LibraryItem] = []
private let userDefaults = UserDefaults.standard
private let libraryKey = "LibraryItems"
private init() {
loadLibrary()
}
func loadLibrary() {
if let data = userDefaults.data(forKey: libraryKey),
let decoded = try? JSONDecoder().decode([LibraryItem].self, from: data) {
libraryItems = decoded
}
}
func saveLibrary() {
if let encoded = try? JSONEncoder().encode(libraryItems) {
userDefaults.set(encoded, forKey: libraryKey)
}
}
func addToLibrary(_ item: LibraryItem) {
if !libraryItems.contains(where: { $0.id == item.id }) {
libraryItems.append(item)
saveLibrary()
Logger.shared.log("Added to library: \(item.title)")
}
}
func removeFromLibrary(_ item: LibraryItem) {
libraryItems.removeAll(where: { $0.id == item.id })
saveLibrary()
Logger.shared.log("Removed from library: \(item.title)")
}
func importFromMiruData(_ miruData: MiruDataStruct, module: ModuleStruct) {
var newLibraryItems: [LibraryItem] = []
for like in miruData.likes {
let libraryItem = LibraryItem(
anilistID: like.anilistID,
title: like.title,
image: like.cover,
url: like.gogoSlug,
module: module,
dateAdded: Date()
)
newLibraryItems.append(libraryItem)
Logger.shared.log("Importing item: \(libraryItem.title)")
}
DispatchQueue.main.async {
self.libraryItems.append(contentsOf: newLibraryItems)
self.saveLibrary()
Logger.shared.log("Completed importing \(newLibraryItems.count) items")
}
}
}

View file

@ -1,95 +0,0 @@
//
// LibraryView.swift
// Sora
//
// Created by Francesco on 18/12/24.
//
import SwiftUI
import Kingfisher
import Foundation
struct LibraryItem: Identifiable, Codable {
var id = UUID()
let anilistID: Int
let title: String
let image: String
let url: String
let module: ModuleStruct
var dateAdded: Date
}
struct LibraryView: View {
@StateObject private var libraryManager = LibraryManager.shared
var body: some View {
NavigationView {
ScrollView {
if libraryManager.libraryItems.isEmpty {
emptyLibraryView
} else {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))], spacing: 20) {
ForEach(libraryManager.libraryItems.sorted(by: { $0.dateAdded > $1.dateAdded })) { item in
NavigationLink(destination: MediaView(module: item.module, item: ItemResult(name: item.title, imageUrl: item.image, href: item.url))) {
itemView(item)
}
}
}
.padding()
}
}
.navigationTitle("Library")
}
.navigationViewStyle(StackNavigationViewStyle())
}
var emptyLibraryView: some View {
VStack(spacing: 8) {
Image(systemName: "books.vertical")
.font(.system(size: 75))
.foregroundColor(.secondary)
Text("Your library is empty")
.font(.headline)
.multilineTextAlignment(.center)
Text("Start by adding items you find in the search results or by importing Miru bookmarks from settings!")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.position(x: UIScreen.main.bounds.width / 2, y: UIScreen.main.bounds.height / 3)
}
func itemView(_ item: LibraryItem) -> some View {
VStack() {
ZStack(alignment: .bottomTrailing) {
KFImage(URL(string: item.image))
.resizable()
.aspectRatio(2/3, contentMode: .fill)
.cornerRadius(10)
.frame(width: 150, height: 225)
KFImage(URL(string: item.module.iconURL))
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 30, height: 30)
.background(Color.white)
.clipShape(Circle())
.padding(5)
}
Text(item.title)
.font(.subheadline)
.foregroundColor(Color.primary)
.padding([.leading, .bottom], 8)
.lineLimit(1)
}
.contextMenu {
Button(role: .destructive) {
libraryManager.removeFromLibrary(item)
} label: {
Label("Remove from Library", systemImage: "trash")
}
}
}
}

View file

@ -7,12 +7,16 @@
import SwiftUI
import Kingfisher
import SwiftyJSON
struct EpisodeLink: Identifiable {
let id = UUID()
let number: Int
let href: String
}
struct EpisodeCell: View {
let episode: String
let episodeID: Int
let imageUrl: String
let progress: Double
let itemID: Int
@ -92,10 +96,12 @@ struct EpisodeCell: View {
func parseEpisodeDetails(data: Data) {
do {
let json = try JSON(data: data)
guard let episodeDetails = json["episodes"]["\(episodeID + 1)"].dictionary,
let title = episodeDetails["title"]?.dictionary,
let image = episodeDetails["image"]?.string else {
let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
guard let json = jsonObject as? [String: Any],
let episodes = json["episodes"] as? [String: Any],
let episodeDetails = episodes["\(episodeID + 1)"] as? [String: Any],
let title = episodeDetails["title"] as? [String: String],
let image = episodeDetails["image"] as? String else {
print("Invalid response format")
DispatchQueue.main.async {
self.isLoading = false
@ -104,7 +110,7 @@ struct EpisodeCell: View {
}
DispatchQueue.main.async {
self.episodeTitle = title["en"]?.string ?? ""
self.episodeTitle = title["en"] ?? ""
self.episodeImageUrl = image
self.isLoading = false
}

View file

@ -0,0 +1,323 @@
//
// MediaInfoView.swift
// Sora
//
// Created by Francesco on 05/01/25.
//
import SwiftUI
import Kingfisher
import SafariServices
struct MediaItem: Identifiable {
let id = UUID()
let description: String
let aliases: String
let airdate: String
}
struct MediaInfoView: View {
let title: String
let imageUrl: String
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 isLoading: Bool = true
@State var showFullSynopsis: Bool = false
@AppStorage("externalPlayer") private var externalPlayer: String = "Default"
@StateObject private var jsController = JSController()
@EnvironmentObject var moduleManager: ModuleManager
var body: some View {
Group {
if isLoading {
ProgressView()
.padding()
} else {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .top, spacing: 10) {
KFImage(URL(string: imageUrl))
.resizable()
.aspectRatio(2/3, contentMode: .fill)
.cornerRadius(10)
.frame(width: 150, height: 225)
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.system(size: 17))
.fontWeight(.bold)
if !aliases.isEmpty && aliases != title {
Text(aliases)
.font(.system(size: 13))
.foregroundColor(.secondary)
}
Spacer()
HStack(alignment: .center, spacing: 12) {
HStack(spacing: 4) {
Image(systemName: "calendar")
.resizable()
.frame(width: 15, height: 15)
.foregroundColor(.secondary)
Text(airdate)
.font(.system(size: 12))
.foregroundColor(.secondary)
}
.padding(4)
}
HStack(alignment: .center, spacing: 12) {
Button(action: {
openSafariViewController(with: href)
}) {
HStack(spacing: 4) {
Text(module.metadata.sourceName)
.font(.system(size: 13))
.foregroundColor(.primary)
Image(systemName: "safari")
.resizable()
.frame(width: 20, height: 20)
.foregroundColor(.primary)
}
.padding(4)
.background(Capsule().fill(Color.accentColor.opacity(0.4)))
}
Button(action: {
}) {
Image(systemName: "ellipsis.circle")
.resizable()
.frame(width: 20, height: 20)
}
}
}
}
if !synopsis.isEmpty {
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .center) {
Text("Synopsis")
.font(.system(size: 18))
.fontWeight(.bold)
Spacer()
Button(action: {
showFullSynopsis.toggle()
}) {
Text(showFullSynopsis ? "Less" : "More")
.font(.system(size: 14))
}
}
Text(synopsis)
.lineLimit(showFullSynopsis ? nil : 4)
.font(.system(size: 14))
}
}
HStack {
Button(action: {
}) {
HStack {
Image(systemName: "play.fill")
.foregroundColor(.primary)
Text("Start Watching")
.font(.headline)
.foregroundColor(.primary)
}
.padding()
.frame(maxWidth: .infinity)
.background(Color.accentColor)
.cornerRadius(10)
}
Button(action: {
}) {
Image(systemName: "bookmark")
.resizable()
.frame(width: 20, height: 27)
}
}
if !episodeLinks.isEmpty {
VStack(alignment: .leading, spacing: 10) {
Text("Episodes")
.font(.system(size: 18))
.fontWeight(.bold)
ForEach(episodeLinks.indices, id: \.self) { i in
let ep = episodeLinks[i]
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
EpisodeCell(episode: ep.href, episodeID: ep.number - 1, progress: progress, itemID: itemID ?? 0)
.onTapGesture {
fetchStream(href: ep.href)
}
}
}
}
}
.padding()
.navigationBarTitleDisplayMode(.inline)
.navigationBarTitle("")
.navigationViewStyle(StackNavigationViewStyle())
}
}
}
.onAppear {
fetchDetails()
fetchItemID(byTitle: title) { result in
switch result {
case .success(let id):
itemID = id
case .failure(let error):
print("Failed to fetch Item ID: \(error)")
}
}
}
}
func fetchDetails() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
Task {
do {
let jsContent = try moduleManager.getModuleContent(module)
jsController.loadScript(jsContent)
if(module.metadata.asyncJS == false || module.metadata.asyncJS == nil) {
jsController.fetchDetails(url: href) { items, episodes in
if let item = items.first {
self.synopsis = item.description
self.aliases = item.aliases
self.airdate = item.airdate
}
self.episodeLinks = episodes
self.isLoading = false
}
}
else {
jsController.fetchDetailsJS(url: href) { items, episodes in
if let item = items.first {
self.synopsis = item.description
self.aliases = item.aliases
self.airdate = item.airdate
}
self.episodeLinks = episodes
self.isLoading = false
}
}
} catch {
print("Error loading module: \(error)")
self.isLoading = false
}
}
}
}
func fetchStream(href: String) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
Task {
do {
let jsContent = try moduleManager.getModuleContent(module)
jsController.loadScript(jsContent)
jsController.fetchStreamUrl(episodeUrl: href) { streamUrl in
if let url = streamUrl {
playStream(url: url, fullURL: url)
}
}
} catch {
print("Error loading module: \(error)")
self.isLoading = false
}
}
}
}
func playStream(url: String, fullURL: String) {
DispatchQueue.main.async {
let videoPlayerViewController = VideoPlayerViewController(module: module)
videoPlayerViewController.streamUrl = url
videoPlayerViewController.fullUrl = fullURL
videoPlayerViewController.modalPresentationStyle = .fullScreen
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootVC = windowScene.windows.first?.rootViewController {
rootVC.present(videoPlayerViewController, animated: true, completion: nil)
}
}
}
private func openSafariViewController(with urlString: String) {
guard let url = URL(string: urlString) else {
print("Unable to open the webpage")
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)
}
}
private func fetchItemID(byTitle title: String, completion: @escaping (Result<Int, Error>) -> 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()
}
}

View file

@ -1,407 +0,0 @@
//
// MediaExtraction.swift
// Sora
//
// Created by Francesco on 18/12/24.
//
import SwiftUI
import SwiftSoup
extension MediaView {
func fetchItemDetails() {
guard !isFetchingStream else { return }
isFetchingStream = true
guard let url = URL(string: item.href.hasPrefix("https") ? item.href : "\(module.module[0].details.baseURL.hasSuffix("/") ? module.module[0].details.baseURL : "\(module.module[0].details.baseURL)/")\(item.href.hasPrefix("/") ? String(item.href.dropFirst()) : item.href)") else { return }
URLSession.custom.dataTask(with: url) { data, _, error in
defer { isLoading = false }
guard let data = data, error == nil else { return }
do {
let html = String(data: data, encoding: .utf8) ?? ""
let document = try SwiftSoup.parse(html)
let details = module.module[0].details
let episodes = module.module[0].episodes
let aliases = (try? document.select(details.aliases.selector).attr(details.aliases.attribute)) ?? ""
let synopsis = (try? document.select(details.synopsis).text()) ?? ""
let airdate = (try? document.select(details.airdate).text()) ?? ""
let stars = (try? document.select(details.stars).text()) ?? ""
let episodeElements = try document.select(episodes.selector)
var episodeList = (try? episodeElements.map { try $0.attr("href") }) ?? []
if module.module[0].episodes.order == "reversed" {
episodeList.reverse()
}
DispatchQueue.main.async {
self.aliases = aliases
self.synopsis = synopsis
self.airdate = airdate
self.stars = stars
self.episodes = episodeList
}
} catch {
print("Error parsing HTML: \(error)")
Logger.shared.log("Error parsing HTML: \(error)")
}
}.resume()
isFetchingStream = false
}
func fetchEpisodeStream(urlString: String) {
guard var url = URL(string: urlString.hasPrefix("https") ? urlString : "\(module.module[0].details.baseURL)\(urlString)") else { return }
Logger.shared.log("Pressed episode button")
let dispatchGroup = DispatchGroup()
let pageRedirects = module.module[0].details.pageRedirects ?? false
if pageRedirects {
dispatchGroup.enter() // Start tracking the redirect task
URLSession.custom.dataTask(with: url) { data, _, error in
guard let data = data, error == nil else {
dispatchGroup.leave() // End tracking if there's an error
return
}
let html = String(data: data, encoding: .utf8) ?? ""
let redirectedUrl = extractFromRedirectURL(from: html)
if let redirect = redirectedUrl, let newURL = URL(string: redirect) {
url = newURL
}
dispatchGroup.leave() // End tracking after successful execution
}.resume()
}
dispatchGroup.notify(queue: .main) { // This block executes after all tasks
URLSession.custom.dataTask(with: url) { data, _, error in
guard let data = data, error == nil else { return }
let html = String(data: data, encoding: .utf8) ?? ""
let streamType = module.stream
let streamURLs = extractStreamURLs(from: html, streamType: streamType)
if module.extractor == "dub-sub" {
Logger.shared.log("extracting for dub-sub")
let dubSubURLs = extractDubSubURLs(from: html)
let subURLs = dubSubURLs.filter { $0.type == "SUB" }.map { $0.url }
let dubURLs = dubSubURLs.filter { $0.type == "DUB" }.map { $0.url }
if !subURLs.isEmpty || !dubURLs.isEmpty {
DispatchQueue.main.async {
self.presentStreamSelection(subURLs: subURLs, dubURLs: dubURLs, fullURL: urlString)
}
} else {
DispatchQueue.main.async {
self.playStream(urlString: streamURLs.first, fullURL: urlString)
}
}
} else if module.extractor == "pattern-mp4" || module.extractor == "pattern-HLS" {
Logger.shared.log("extracting for pattern-mp4/hls")
let patternURL = extractPatternURL(from: html)
guard let patternURL = patternURL else { return }
URLSession.custom.dataTask(with: patternURL) { data, _, error in
guard let data = data, error == nil else { return }
let patternHTML = String(data: data, encoding: .utf8) ?? ""
let mp4URLs = extractStreamURLs(from: patternHTML, streamType: streamType).map { $0.replacingOccurrences(of: "amp;", with: "") }
DispatchQueue.main.async {
self.playStream(urlString: mp4URLs.first, fullURL: urlString)
}
}.resume()
} else if module.extractor == "pattern" {
Logger.shared.log("extracting for pattern")
let patternURL = extractPatternURL(from: html)
DispatchQueue.main.async {
self.playStream(urlString: patternURL?.absoluteString, fullURL: urlString)
}
} else if module.extractor == "voe" {
Logger.shared.log("extracting for voe")
let voeUrl = extractVoeStream(from: html)
DispatchQueue.main.async {
self.playStream(urlString: voeUrl?.absoluteString, fullURL: urlString)
}
} else {
DispatchQueue.main.async {
self.playStream(urlString: streamURLs.first, fullURL: urlString)
}
}
}.resume()
}
}
func extractStreamURLs(from html: String, streamType: String) -> [String] {
let pattern: String
switch streamType {
case "HLS":
pattern = #"https:\/\/[^"\s<>]+\.m3u8(?:\?[^\s"'<>]+)?"#
case "MP4":
pattern = #"https:\/\/[^"\s<>]+\.mp4(?:\?[^\s"'<>]+)?"#
default:
return []
}
do {
Logger.shared.log(streamType)
let regex = try NSRegularExpression(pattern: pattern, options: [])
let matches = regex.matches(in: html, options: [], range: NSRange(html.startIndex..., in: html))
return matches.compactMap {
Range($0.range, in: html).map { String(html[$0]) }
}
} catch {
print("Invalid regex: \(error)")
Logger.shared.log("Invalid regex: \(error)")
return []
}
}
func extractPatternURL(from html: String) -> URL? {
var pattern = module.module[0].episodes.pattern
if module.extractor == "pattern" {
if let data = Data(base64Encoded: pattern), let decodedPattern = String(data: data, encoding: .utf8) {
pattern = decodedPattern
} else {
print("Failed to decode base64 pattern")
Logger.shared.log("Failed to decode base64 pattern")
return nil
}
}
do {
let regex = try NSRegularExpression(pattern: pattern, options: [])
let range = NSRange(html.startIndex..<html.endIndex, in: html)
if let match = regex.firstMatch(in: html, options: [], range: range),
let matchRange = Range(match.range, in: html) {
var urlString = String(html[matchRange])
urlString = urlString.replacingOccurrences(of: "amp;", with: "")
urlString = urlString.replacingOccurrences(of: "\"", with: "")
if let httpsRange = urlString.range(of: "https") {
urlString = String(urlString[httpsRange.lowerBound...])
}
return URL(string: urlString)
}
} catch {
print("Invalid regex: \(error)")
Logger.shared.log("Invalid regex: \(error)")
}
return nil
}
func extractDubSubURLs(from htmlContent: String) -> [(type: String, url: String)] {
let pattern = #""type":"(SUB|DUB)","url":"(.*?\.m3u8)""#
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {
return []
}
let range = NSRange(htmlContent.startIndex..., in: htmlContent)
let matches = regex.matches(in: htmlContent, range: range)
return matches.compactMap { match in
if match.numberOfRanges == 3,
let typeRange = Range(match.range(at: 1), in: htmlContent),
let urlRange = Range(match.range(at: 2), in: htmlContent) {
let type = String(htmlContent[typeRange])
let urlString = String(htmlContent[urlRange]).replacingOccurrences(of: "\\/", with: "/")
Logger.shared.log(urlString)
return (type, urlString)
}
return nil
}
}
/// Grabs hls stream from voe sites
func extractVoeStream(from html: String) -> URL? {
let hlsPattern = "'hls': '(.*?)'"
guard let regex = try? NSRegularExpression(pattern: hlsPattern, options: []) else { return nil }
let range = NSRange(html.startIndex..., in: html)
if let match = regex.firstMatch(in: html, options: [], range: range),
let matchRange = Range(match.range(at: 1), in: html) {
let base64Hls = String(html[matchRange])
guard let data = Data(base64Encoded: base64Hls),
let decodedURLString = String(data: data, encoding: .utf8)
else { return nil }
return URL(string: decodedURLString)
}
return nil
}
func presentStreamSelection(subURLs: [String], dubURLs: [String], fullURL: String) {
let uniqueSubURLs = Array(Set(subURLs))
let uniqueDubURLs = Array(Set(dubURLs))
if uniqueSubURLs.count == 1 && uniqueDubURLs.isEmpty {
self.playStream(urlString: uniqueSubURLs.first, fullURL: fullURL)
return
}
if uniqueDubURLs.count == 1 && uniqueSubURLs.isEmpty {
self.playStream(urlString: uniqueDubURLs.first, fullURL: fullURL)
return
}
let alert = UIAlertController(title: "Select Stream", message: "Choose the audio type", preferredStyle: .actionSheet)
if !uniqueDubURLs.isEmpty {
for dubURL in uniqueDubURLs {
alert.addAction(UIAlertAction(title: "DUB", style: .default) { _ in
self.playStream(urlString: dubURL, fullURL: fullURL)
})
}
}
if !uniqueSubURLs.isEmpty {
for subURL in uniqueSubURLs {
alert.addAction(UIAlertAction(title: "SUB", style: .default) { _ in
self.playStream(urlString: subURL, fullURL: fullURL)
})
}
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
DispatchQueue.main.async {
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootVC = windowScene.windows.first?.rootViewController {
if let popoverController = alert.popoverPresentationController {
popoverController.sourceView = rootVC.view
popoverController.sourceRect = CGRect(x: rootVC.view.bounds.midX, y: rootVC.view.bounds.midY, width: 0, height: 0)
popoverController.permittedArrowDirections = []
}
rootVC.present(alert, animated: true, completion: nil)
}
}
}
/// Extracts the URL from a redirect page
/// Example: href="/redirect/1234567" -> https://baseUrl.com/redirect/1234567
func extractFromRedirectURL(from html: String) -> String? {
let pattern = #"href="\/redirect\/\d+""#
do {
let regex = try NSRegularExpression(pattern: pattern, options: [])
let range = NSRange(html.startIndex..<html.endIndex, in: html)
if let match = regex.firstMatch(in: html, options: [], range: range),
let matchRange = Range(match.range, in: html) {
var urlString = String(html[matchRange])
urlString = urlString.replacingOccurrences(of: "href=\"", with: "")
urlString = urlString.replacingOccurrences(of: "\"", with: "")
// Ensure the baseURL ends with "/" before appending the path
let baseURL = module.module[0].details.baseURL
let redirectUrl = baseURL + urlString
let finalUrl = fetchRedirectedURLFromHeader(url: URL(string: redirectUrl)!)
return finalUrl
}
} catch {
print("Invalid regex: \(error)")
Logger.shared.log("Invalid regex: \(error)")
}
return nil
}
/// Fetches the redirected URL from the header of a given URL
/// Header Parameter: Location
func fetchRedirectedURLFromHeader(url: URL) -> String? {
let semaphore = DispatchSemaphore(value: 0) // To block the thread until the task completes
var redirectedURLString: String?
var request = URLRequest(url: url)
request.httpMethod = "HEAD" // Use HEAD to get only headers
let delegate = RedirectHandler()
let sessionConfig = URLSessionConfiguration.default
let session = URLSession(configuration: sessionConfig, delegate: delegate, delegateQueue: nil)
session.dataTask(with: request) { _, response, error in
// Extract httpResponse as a standalone variable
guard let httpResponse = response as? HTTPURLResponse else {
Logger.shared.log("Invalid response for URL: \(url)")
semaphore.signal()
return
}
// Process the httpResponse for redirection logic
if (httpResponse.statusCode == 301 || httpResponse.statusCode == 302),
let location = httpResponse.value(forHTTPHeaderField: "Location"),
let redirectedURL = URL(string: location) {
redirectedURLString = redirectedURL.absoluteString
Logger.shared.log("Redirected URL: \(redirectedURLString ?? "nil")")
} else {
if let error = error {
Logger.shared.log("Error fetching redirected URL: \(error.localizedDescription)")
} else {
Logger.shared.log("No redirection for URL: \(url)")
}
}
semaphore.signal() // Signal the semaphore to resume execution
}.resume()
semaphore.wait() // Wait for the network task to complete
if redirectedURLString?.contains("voe.sx") == true {
return voeUrlHandler(url: URL(string: redirectedURLString!)!)
}
else {
return redirectedURLString
}
}
/// Voe uses a custom handler to extract the video URL from the page
/// The site uses windows.location.href to redirect to the video page, usally another domain but with the same path
/// The replacement URL is hardcoded right now TODO: Make it dynamic
func voeUrlHandler(url: URL) -> String? {
let urlString = url.absoluteString
// Check if the URL is a voe.sx URL
guard urlString.contains("voe.sx") else {
Logger.shared.log("Not a voe.sx URL")
return nil
}
// Extract the path from the URL and append it to the hardcoded replacement URL
// Example: https://voe.sx/e/1234567 -> /e/1234567
let hardcodedURL = "https://sandratableother.com"
let finishedUrl = urlString.replacingOccurrences(of: "https://voe.sx", with: hardcodedURL)
return finishedUrl
}
}
/// Custom handler to handle HTTP redirections and prevent them
class RedirectHandler: NSObject, URLSessionDelegate, URLSessionTaskDelegate {
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping @Sendable (URLRequest?) -> Void
) {
completionHandler(nil)
}
}

View file

@ -1,377 +0,0 @@
//
// MediaView.swift
// Sora
//
// Created by Francesco on 18/12/24.
//
import AVKit
import SwiftUI
import Kingfisher
import SwiftyJSON
import SafariServices
struct MediaView: View {
let module: ModuleStruct
let item: ItemResult
@State var aliases: String = ""
@State var synopsis: String = ""
@State var airdate: String = ""
@State var stars: String = ""
@State var episodes: [String] = []
@State var isLoading: Bool = true
@State var showFullSynopsis: Bool = false
@State var itemID: Int?
@State private var selectedEpisode: String = ""
@State private var selectedEpisodeNumber: Int = 0
@State private var episodeRange: ClosedRange<Int> = 0...99
@State var selectedRange: String = "1-100"
@State var isFetchingStream: Bool = false
@AppStorage("externalPlayer") private var externalPlayer: String = "Default"
@StateObject private var libraryManager = LibraryManager.shared
var body: some View {
Group {
if isLoading {
ProgressView()
.padding()
} else {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .top, spacing: 10) {
KFImage(URL(string: item.imageUrl))
.resizable()
.aspectRatio(2/3, contentMode: .fill)
.cornerRadius(10)
.frame(width: 150, height: 225)
VStack(alignment: .leading, spacing: 4) {
Text(item.name)
.font(.system(size: 17))
.fontWeight(.bold)
if !aliases.isEmpty && aliases != item.name {
Text(aliases)
.font(.system(size: 13))
.foregroundColor(.secondary)
}
Spacer()
HStack(alignment: .center, spacing: 12) {
Text(module.name)
.font(.system(size: 13))
.padding(4)
.background(Capsule().fill(Color.accentColor.opacity(0.4)))
Button(action: {
}) {
Image(systemName: "ellipsis.circle")
.resizable()
.frame(width: 20, height: 20)
}
Button(action: {
openSafariViewController(with: "\(module.module[0].details.baseURL)")
}) {
Image(systemName: "safari")
.resizable()
.frame(width: 20, height: 20)
}
}
}
}
if !synopsis.isEmpty {
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .center) {
Text("Synopsis")
.font(.system(size: 18))
.fontWeight(.bold)
Spacer()
Button(action: {
showFullSynopsis.toggle()
}) {
Text(showFullSynopsis ? "Less" : "More")
.font(.system(size: 14))
}
}
Text(synopsis)
.lineLimit(showFullSynopsis ? nil : 4)
.font(.system(size: 14))
}
}
HStack {
Button(action: {
startWatchingFirstUnfinishedEpisode()
}) {
HStack {
Image(systemName: "play.fill")
.foregroundColor(.primary)
Text("Start Watching")
.font(.headline)
.foregroundColor(.primary)
}
.padding()
.frame(maxWidth: .infinity)
.background(Color.accentColor)
.cornerRadius(10)
}
Button(action: {
if isItemInLibrary() {
removeFromLibrary()
} else {
addToLibrary()
}
}) {
Image(systemName: isItemInLibrary() ? "bookmark.fill" : "bookmark")
.resizable()
.frame(width: 20, height: 27)
}
}
if !episodes.isEmpty {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("Episodes")
.font(.system(size: 18))
.fontWeight(.bold)
Spacer()
if episodes.count > 100 {
Menu {
ForEach(0..<(episodes.count / 100) + 1, id: \.self) { index in
let start = index * 100 + 1
let end = min((index + 1) * 100, episodes.count)
Button(action: {
episodeRange = (start - 1)...(end - 1)
selectedRange = "\(start)-\(end)"
}) {
Text("\(start)-\(end)")
}
}
} label: {
Text(selectedRange)
.font(.system(size: 14))
}
}
}
ForEach(episodeRange, id: \.self) { index in
if index < episodes.count {
let episodeURL = episodes[index].hasPrefix("https") ? episodes[index] : "\(module.module[0].details.baseURL)\(episodes[index])"
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(episodeURL)")
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(episodeURL)")
let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0
EpisodeCell(episode: episodes[index], episodeID: index, imageUrl: item.imageUrl, progress: progress, itemID: itemID ?? 0)
.onTapGesture {
selectedEpisode = episodes[index]
selectedEpisodeNumber = index + 1
fetchEpisodeStream(urlString: episodeURL)
}
}
}
}
}
}
.padding()
.navigationBarTitleDisplayMode(.inline)
.navigationBarTitle(item.name)
.navigationViewStyle(StackNavigationViewStyle())
}
}
}
.onAppear {
fetchItemDetails()
fetchItemID(byTitle: item.name) { result in
switch result {
case .success(let id):
itemID = id
Logger.shared.log("Fetched Item ID: \(id)")
case .failure(let error):
print("Failed to fetch Item ID: \(error)")
Logger.shared.log("Failed to fetch Item ID: \(error)")
}
}
}
}
func isItemInLibrary() -> Bool {
return libraryManager.libraryItems.contains(where: { $0.url == item.href })
}
func addToLibrary() {
let libraryItem = LibraryItem(
anilistID: itemID ?? 0,
title: item.name,
image: item.imageUrl,
url: item.href,
module: module,
dateAdded: Date()
)
libraryManager.addToLibrary(libraryItem)
}
func removeFromLibrary() {
if let libraryItem = libraryManager.libraryItems.first(where: { $0.url == item.href }) {
libraryManager.removeFromLibrary(libraryItem)
}
}
private func startWatchingFirstUnfinishedEpisode() {
for (index, episode) in episodes.enumerated() {
let episodeURL = episode.hasPrefix("https") ? episode : "\(module.module[0].details.baseURL)\(episode)"
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(episodeURL)")
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(episodeURL)")
let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0
if progress < 0.90 {
selectedEpisode = episode
selectedEpisodeNumber = index + 1
fetchEpisodeStream(urlString: episodeURL)
break
}
}
}
func playStream(urlString: String?, fullURL: String) {
guard let streamUrl = urlString else { return }
if externalPlayer == "Infuse" || externalPlayer == "VLC" || externalPlayer == "OutPlayer" || externalPlayer == "nPlayer" {
var scheme: String
switch externalPlayer {
case "Infuse":
scheme = "infuse://x-callback-url/play?url="
case "VLC":
scheme = "vlc://"
case "OutPlayer":
scheme = "outplayer://"
case "nPlayer":
scheme = "nplayer-"
default:
scheme = ""
}
openInExternalPlayer(scheme: scheme, url: streamUrl)
Logger.shared.log("Opening external app with scheme: \(scheme)")
return
} else if externalPlayer == "Sora" {
DispatchQueue.main.async {
let customMediaPlayer = CustomMediaPlayer(
module: module,
urlString: streamUrl,
fullUrl: fullURL,
title: item.name,
episodeNumber: selectedEpisodeNumber,
onWatchNext: {
selectNextEpisode()
}
)
let hostingController = UIHostingController(rootView: customMediaPlayer)
hostingController.modalPresentationStyle = .fullScreen
Logger.shared.log("Opening custom media player with url: \(streamUrl)")
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootVC = windowScene.windows.first?.rootViewController {
rootVC.present(hostingController, animated: true, completion: nil)
}
}
return
}
DispatchQueue.main.async {
let videoPlayerViewController = VideoPlayerViewController(module: module)
videoPlayerViewController.streamUrl = streamUrl
videoPlayerViewController.fullUrl = fullURL
videoPlayerViewController.modalPresentationStyle = .fullScreen
Logger.shared.log("Opening video player with url: \(streamUrl)")
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootVC = windowScene.windows.first?.rootViewController {
rootVC.present(videoPlayerViewController, animated: true, completion: nil)
}
}
}
private func selectNextEpisode() {
guard let currentEpisodeIndex = episodes.firstIndex(of: selectedEpisode) else { return }
let nextEpisodeIndex = currentEpisodeIndex + 1
if nextEpisodeIndex < episodes.count {
selectedEpisode = episodes[nextEpisodeIndex]
selectedEpisodeNumber = nextEpisodeIndex + 1
let nextEpisodeURL = "\(module.module[0].details.baseURL)\(episodes[nextEpisodeIndex])"
fetchEpisodeStream(urlString: nextEpisodeURL)
}
}
private func openSafariViewController(with urlString: String) {
guard let url = URL(string: item.href.hasPrefix("https") ? item.href : "\(module.module[0].details.baseURL.hasSuffix("/") ? module.module[0].details.baseURL : "\(module.module[0].details.baseURL)/")\(item.href.hasPrefix("/") ? String(item.href.dropFirst()) : item.href)") else {
Logger.shared.log("Unable to open the webpage")
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)
}
}
private func openInExternalPlayer(scheme: String, url: String) {
guard let streamUrl = URL(string: "\(scheme)\(url)") else {
Logger.shared.log("Unable to open the stream: '\(scheme)\(url)'")
return
}
UIApplication.shared.open(streamUrl, options: [:], completionHandler: nil)
Logger.shared.log("Unable to open the stream: 'streamUrl'")
}
func fetchItemID(byTitle title: String, completion: @escaping (Result<Int, Error>) -> 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
}
let json = JSON(data)
if let id = json["data"]["Media"]["id"].int {
completion(.success(id))
} else {
let error = NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid response"])
completion(.failure(error))
}
}.resume()
}
}

211
Sora/Views/SearchView.swift Normal file
View file

@ -0,0 +1,211 @@
//
// SearchView.swift
// Sora
//
// Created by Francesco on 05/01/25.
//
import SwiftUI
import Kingfisher
struct SearchItem: Identifiable {
let id = UUID()
let title: String
let imageUrl: String
let href: String
}
struct SearchView: View {
@AppStorage("selectedModuleId") private var selectedModuleId: String?
@StateObject private var jsController = JSController()
@EnvironmentObject var moduleManager: ModuleManager
@State private var searchItems: [SearchItem] = []
@State private var selectedSearchItem: SearchItem?
@State private var isSearching = false
@State private var searchText = ""
@State private var hasNoResults = false
private var selectedModule: ScrapingModule? {
guard let id = selectedModuleId else { return nil }
return moduleManager.modules.first { $0.id.uuidString == id }
}
var body: some View {
NavigationView {
ScrollView {
VStack(spacing: 0) {
SearchBar(text: $searchText, onSearchButtonClicked: performSearch)
.padding()
.disabled(selectedModule == nil)
if selectedModule == nil {
VStack(spacing: 8) {
Text("No Module Selected")
.font(.headline)
Text("Please select a module from settings")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.frame(maxWidth: .infinity)
.background(Color(.systemBackground))
.shadow(color: Color.black.opacity(0.1), radius: 2, y: 1)
}
if isSearching {
ProgressView()
.padding()
} else if hasNoResults {
VStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.font(.largeTitle)
.foregroundColor(.secondary)
Text("No Results Found")
.font(.headline)
Text("Try different keywords/titles")
.font(.caption)
.foregroundColor(.secondary)
}
.padding()
.frame(maxWidth: .infinity)
}
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))], spacing: 16) {
ForEach(searchItems) { item in
NavigationLink(destination: MediaInfoView(title: item.title, imageUrl: item.imageUrl, href: item.href, module: selectedModule!)) {
VStack {
KFImage(URL(string: item.imageUrl))
.resizable()
.aspectRatio(2/3, contentMode: .fill)
.cornerRadius(10)
.frame(width: 150, height: 225)
Text(item.title)
.font(.subheadline)
.foregroundColor(Color.primary)
.padding([.leading, .bottom], 8)
.lineLimit(1)
}
}
}
}
.padding()
}
}
.navigationTitle("Search")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
if let selectedModule = selectedModule {
Text(selectedModule.metadata.sourceName)
.font(.headline)
.foregroundColor(.secondary)
}
Menu {
ForEach(moduleManager.modules) { module in
Button {
selectedModuleId = module.id.uuidString
} label: {
HStack {
KFImage(URL(string: module.metadata.iconUrl))
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 20, height: 20)
.cornerRadius(4)
Text(module.metadata.sourceName)
Spacer()
if module.id.uuidString == selectedModuleId {
Image(systemName: "checkmark")
}
}
}
}
} label: {
Image(systemName: "chevron.up.chevron.down")
.foregroundColor(.secondary)
}
}
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
.onChange(of: selectedModuleId) { _ in
if !searchText.isEmpty {
performSearch()
}
}
}
private func performSearch() {
guard !searchText.isEmpty, let module = selectedModule else {
searchItems = []
hasNoResults = false
return
}
isSearching = true
hasNoResults = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
Task {
do {
let jsContent = try moduleManager.getModuleContent(module)
jsController.loadScript(jsContent)
if(module.metadata.asyncJS == false || module.metadata.asyncJS == nil) {
jsController.fetchSearchResults(keyword: searchText, module: module) { items in
searchItems = items
hasNoResults = items.isEmpty
isSearching = false
}
} else {
jsController.fetchJsSearchResults(keyword: searchText, module: module) { items in
searchItems = items
hasNoResults = items.isEmpty
isSearching = false
}
}
} catch {
print("Error loading module: \(error)")
isSearching = false
hasNoResults = true
}
}
}
}
}
struct SearchBar: View {
@Binding var text: String
var onSearchButtonClicked: () -> Void
var body: some View {
HStack {
TextField("Search...", text: $text, onCommit: onSearchButtonClicked)
.padding(7)
.padding(.horizontal, 25)
.background(Color(.systemGray6))
.cornerRadius(8)
.overlay(
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.secondary)
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.padding(.leading, 8)
if !text.isEmpty {
Button(action: {
self.text = ""
}) {
Image(systemName: "multiply.circle.fill")
.foregroundColor(.secondary)
.padding(.trailing, 8)
}
}
}
)
}
}
}

View file

@ -1,236 +0,0 @@
//
// SearchResultsView.swift
// Sora
//
// Created by Francesco on 18/12/24.
//
import SwiftUI
import SwiftSoup
import Kingfisher
struct SearchResultsView: View {
let module: ModuleStruct?
let searchText: String
@State private var searchResults: [ItemResult] = []
@State private var isLoading: Bool = true
@State private var filter: FilterType = .all
@AppStorage("listSearch") private var isListSearchEnabled: Bool = false
enum FilterType: String, CaseIterable {
case all = "All"
case dub = "Dub"
case sub = "Sub"
case ova = "OVA"
case ona = "ONA"
case movie = "Movie"
}
var body: some View {
if isListSearchEnabled {
oldUI
} else {
modernUI
}
}
var modernUI: some View {
VStack {
if isLoading {
ProgressView()
.padding()
} else if searchResults.isEmpty {
Text("No results found")
.foregroundColor(.secondary)
.padding()
} else {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))], spacing: 20) {
ForEach(filteredResults) { result in
NavigationLink(destination: MediaView(module: module!, item: result)) {
VStack {
KFImage(URL(string: result.imageUrl))
.resizable()
.aspectRatio(2/3, contentMode: .fill)
.cornerRadius(10)
.frame(width: 150, height: 225)
Text(result.name)
.font(.subheadline)
.foregroundColor(Color.primary)
.padding([.leading, .bottom], 8)
.lineLimit(1)
}
}
}
}
.padding()
}
.navigationViewStyle(StackNavigationViewStyle())
.navigationTitle("Results")
.toolbar {
filterMenu
}
}
}
.onAppear {
performSearch()
}
}
var oldUI: some View {
VStack {
if isLoading {
ProgressView()
.padding()
} else if searchResults.isEmpty {
Text("No results found")
.foregroundColor(.secondary)
.padding()
} else {
List {
ForEach(filteredResults) { result in
NavigationLink(destination: MediaView(module: module!, item: result)) {
HStack {
KFImage(URL(string: result.imageUrl))
.resizable()
.scaledToFill()
.frame(width: 100, height: 150)
.clipped()
VStack(alignment: .leading) {
Text(result.name)
.font(.system(size: 16))
.padding(.leading, 10)
}
}
.padding(.vertical, 5)
}
}
}
.navigationTitle("Results")
.toolbar {
filterMenu
}
}
}
.navigationViewStyle(StackNavigationViewStyle())
.onAppear {
performSearch()
}
}
var filterMenu: some View {
Menu {
ForEach([FilterType.all], id: \.self) { filter in
Button(action: {
self.filter = filter
performSearch()
}) {
Label(filter.rawValue, systemImage: self.filter == filter ? "checkmark" : "")
}
}
Menu("Audio") {
ForEach([FilterType.dub, FilterType.sub], id: \.self) { filter in
Button(action: {
self.filter = filter
performSearch()
}) {
Label(filter.rawValue, systemImage: self.filter == filter ? "checkmark" : "")
}
}
}
Menu("Format") {
ForEach([FilterType.ova, FilterType.ona, FilterType.movie], id: \.self) { filter in
Button(action: {
self.filter = filter
performSearch()
}) {
Label(filter.rawValue, systemImage: self.filter == filter ? "checkmark" : "")
}
}
}
} label: {
Label("Filter", systemImage: filter == .all ? "line.horizontal.3.decrease.circle" : "line.horizontal.3.decrease.circle.fill")
}
}
var filteredResults: [ItemResult] {
switch filter {
case .all:
return searchResults
case .dub:
return searchResults.filter { $0.name.contains("Dub") || $0.name.contains("ITA") }
case .sub:
return searchResults.filter { !$0.name.contains("Dub") && !$0.name.contains("ITA") }
case .ova, .ona:
return searchResults.filter { $0.name.contains(filter.rawValue) }
case .movie:
return searchResults.filter { $0.name.contains("Movie") || $0.name.contains("Film") }
}
}
func performSearch() {
guard let module = module, !searchText.isEmpty else { return }
let encodedSearchText = searchText.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? searchText
let parameter = module.module[0].search.parameter
let urlString: String
if parameter == "blank" {
urlString = "\(module.module[0].search.url)\(encodedSearchText)"
} else {
urlString = "\(module.module[0].search.url)?\(parameter)=\(encodedSearchText)"
}
guard let url = URL(string: urlString) else { return }
URLSession.custom.dataTask(with: url) { data, _, error in
defer { isLoading = false }
guard let data = data, error == nil else { return }
do {
let html = String(data: data, encoding: .utf8) ?? ""
let document = try SwiftSoup.parse(html)
let elements = try document.select(module.module[0].search.documentSelector)
var results: [ItemResult] = []
for element in elements {
let title = try element.select(module.module[0].search.title).text()
let href = try element.select(module.module[0].search.href).attr("href")
var imageURL = try element.select(module.module[0].search.image.url).attr(module.module[0].search.image.attribute)
if imageURL.contains(",") {
imageURL = imageURL.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.first ?? imageURL
}
if !imageURL.starts(with: "http") {
imageURL = "\(module.module[0].details.baseURL.hasSuffix("/") ? module.module[0].details.baseURL : "\(module.module[0].details.baseURL)/")\(imageURL.hasPrefix("/") ? String(imageURL.dropFirst()) : imageURL)"
}
imageURL = imageURL.replacingOccurrences(of: " ", with: "%20")
// If imageURL is not available or is the same as the baseURL, use a default image
if imageURL.isEmpty || imageURL == module.module[0].details.baseURL + "/" {
imageURL = "https://s4.anilist.co/file/anilistcdn/character/large/default.jpg"
}
let result = ItemResult(name: title, imageUrl: imageURL, href: href)
results.append(result)
}
// Filter out non-searchable modules
if module.module[0].search.searchable == false {
results = results.filter { $0.name.lowercased().contains(searchText.lowercased()) }
}
DispatchQueue.main.async {
self.searchResults = results
}
} catch {
print("Error parsing HTML: \(error)")
Logger.shared.log("Error parsing HTML: \(error)")
}
}.resume()
}
}

View file

@ -1,149 +0,0 @@
//
// SearchView.swift
// Sora
//
// Created by Francesco on 18/12/24.
//
import SwiftUI
struct ItemResult: Identifiable {
let id = UUID()
let name: String
let imageUrl: String
let href: String
}
struct SearchView: View {
@State private var searchText: String = ""
@State private var searchResults: [ItemResult] = []
@State private var navigateToResults: Bool = false
@State private var selectedModule: ModuleStruct?
@State private var showAlert = false
@StateObject private var modulesManager = ModulesManager()
@ObservedObject private var searchHistoryManager = HistoryManager()
var body: some View {
NavigationView {
VStack {
HStack {
Menu {
ForEach(modulesManager.modules, id: \.name) { module in
Button(action: {
selectedModule = module
}) {
Text(module.name)
}
}
} label: {
Label(selectedModule?.name ?? "Select Module", systemImage: "chevron.down")
}
Spacer()
SearchBar(text: $searchText, onSearchButtonClicked: {
if let _ = selectedModule, !searchText.isEmpty {
searchHistoryManager.addSearchHistory(searchText)
navigateToResults = true
} else {
showAlert = true
Logger.shared.log("No Module is selected for the search")
}
})
}
.padding(.horizontal)
List {
if !searchHistoryManager.searchHistory.isEmpty {
Section(header: Text("Search History")) {
ForEach(searchHistoryManager.searchHistory, id: \.self) { historyItem in
Button(action: {
searchText = historyItem
if let _ = selectedModule, !searchText.isEmpty {
navigateToResults = true
} else {
showAlert = true
Logger.shared.log("No Module is selected for the search")
}
}) {
Text(historyItem)
.foregroundColor(.primary)
}
}
.onDelete(perform: searchHistoryManager.deleteHistoryItem)
}
}
}
.navigationTitle("Search")
.onSubmit(of: .search) {
if let _ = selectedModule, !searchText.isEmpty {
navigateToResults = true
} else {
showAlert = true
Logger.shared.log("No Module is selected for the search")
}
}
NavigationLink(
destination: SearchResultsView(module: selectedModule, searchText: searchText),
isActive: $navigateToResults,
label: {
EmptyView()
}
)
.hidden()
}
.onAppear {
modulesManager.loadModules()
NotificationCenter.default.addObserver(forName: .moduleAdded, object: nil, queue: .main) { _ in
modulesManager.loadModules()
}
NotificationCenter.default.addObserver(forName: .moduleRemoved, object: nil, queue: .main) { _ in
modulesManager.loadModules()
}
}
.alert(isPresented: $showAlert) {
Alert(
title: Text("No module selected"),
message: Text("Please select a module before searching."),
dismissButton: .default(Text("OK"))
)
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
struct SearchBar: View {
@Binding var text: String
var onSearchButtonClicked: () -> Void
var body: some View {
HStack {
TextField("Search...", text: $text, onCommit: onSearchButtonClicked)
.padding(7)
.padding(.horizontal, 25)
.background(Color(.systemGray6))
.cornerRadius(8)
.overlay(
HStack {
Image(systemName: "magnifyingglass")
.foregroundColor(.gray)
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.padding(.leading, 8)
if !text.isEmpty {
Button(action: {
self.text = ""
}) {
Image(systemName: "multiply.circle.fill")
.foregroundColor(.gray)
.padding(.trailing, 8)
}
}
}
)
.padding(.horizontal, 10)
}
}
}

View file

@ -0,0 +1,132 @@
//
// SettingsViewModule.swift
// Sora
//
// Created by Francesco on 05/01/25.
//
import SwiftUI
import Kingfisher
struct SettingsViewModule: View {
@AppStorage("selectedModuleId") private var selectedModuleId: String?
@EnvironmentObject var moduleManager: ModuleManager
@State private var errorMessage: String?
@State private var isLoading = false
var body: some View {
VStack {
Form {
ForEach(moduleManager.modules) { module in
HStack {
KFImage(URL(string: module.metadata.iconUrl))
.resizable()
.frame(width: 50, height: 50)
.clipShape(Circle())
.padding(.trailing, 10)
VStack(alignment: .leading) {
HStack(alignment: .bottom, spacing: 4) {
Text(module.metadata.sourceName)
.font(.headline)
.foregroundColor(.primary)
Text("v\(module.metadata.version)")
.font(.subheadline)
.foregroundColor(.secondary)
}
Text("Author: \(module.metadata.author)")
.font(.subheadline)
.foregroundColor(.secondary)
Text("Language: \(module.metadata.language)")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
if module.id.uuidString == selectedModuleId {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.accentColor)
.frame(width: 25, height: 25)
}
}
.contentShape(Rectangle())
.onTapGesture {
selectedModuleId = module.id.uuidString
}
.contextMenu {
Button(action: {
UIPasteboard.general.string = module.metadata.iconUrl
}) {
Label("Copy URL", systemImage: "doc.on.doc")
}
Button(role: .destructive) {
if selectedModuleId == module.id.uuidString {
selectedModuleId = nil
}
moduleManager.deleteModule(module)
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions {
Button(role: .destructive) {
if selectedModuleId == module.id.uuidString {
selectedModuleId = nil
}
moduleManager.deleteModule(module)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.navigationTitle("Modules")
.navigationBarItems(trailing: Button(action: {
showAddModuleAlert()
}) {
Image(systemName: "plus")
.resizable()
.padding(5)
})
}
}
func showAddModuleAlert() {
let alert = UIAlertController(title: "Add Module", message: "Enter the URL of the module file", preferredStyle: .alert)
alert.addTextField { textField in
textField.placeholder = "https://real.url/module.json"
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
alert.addAction(UIAlertAction(title: "Add", style: .default, handler: { _ in
if let url = alert.textFields?.first?.text {
addModule(from: url)
}
}))
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController {
rootViewController.present(alert, animated: true, completion: nil)
}
}
private func addModule(from url: String) {
isLoading = true
errorMessage = nil
Task {
do {
_ = try await moduleManager.addModule(metadataUrl: url)
DispatchQueue.main.async {
isLoading = false
}
} catch {
DispatchQueue.main.async {
isLoading = false
errorMessage = error.localizedDescription
}
}
}
}
}

View file

@ -0,0 +1,139 @@
//
// SettingsView.swift
// Sora
//
// Created by Francesco on 05/01/25.
//
import SwiftUI
struct SettingsView: View {
@EnvironmentObject var settings: Settings
var body: some View {
NavigationView {
Form {
Section(header: Text("Interface")) {
ColorPicker("Accent Color", selection: $settings.accentColor)
HStack() {
Text("Appearance")
Picker("Appearance", selection: $settings.selectedAppearance) {
Text("System").tag(Appearance.system)
Text("Light").tag(Appearance.light)
Text("Dark").tag(Appearance.dark)
}
.pickerStyle(SegmentedPickerStyle())
}
}
Section(header: Text("External Features")) {
NavigationLink(destination: SettingsViewModule()) {
Text("Modules")
}
}
Section(header: Text("Info")) {
Button(action: {
if let url = URL(string: "https://github.com/cranci1/Sora") {
UIApplication.shared.open(url)
}
}) {
HStack {
Text("Sora github repo")
.foregroundColor(.primary)
Spacer()
Image(systemName: "safari")
.foregroundColor(.secondary)
}
}
Button(action: {
if let url = URL(string: "https://github.com/cranci1/Sora/issues") {
UIApplication.shared.open(url)
}
}) {
HStack {
Text("Report an issue")
.foregroundColor(.primary)
Spacer()
Image(systemName: "safari")
.foregroundColor(.secondary)
}
}
Button(action: {
if let url = URL(string: "https://discord.gg/x7hppDWFDZ") {
UIApplication.shared.open(url)
}
}) {
HStack {
Text("Join the Discord")
.foregroundColor(.primary)
Spacer()
Image(systemName: "safari")
.foregroundColor(.secondary)
}
}
}
}
.navigationTitle("Settings")
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
enum Appearance: String, CaseIterable, Identifiable {
case system, light, dark
var id: String { self.rawValue }
}
class Settings: ObservableObject {
@Published var accentColor: Color {
didSet {
saveAccentColor(accentColor)
}
}
@Published var selectedAppearance: Appearance {
didSet {
UserDefaults.standard.set(selectedAppearance.rawValue, forKey: "selectedAppearance")
updateAppearance()
}
}
init() {
if let colorData = UserDefaults.standard.data(forKey: "accentColor"),
let uiColor = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(colorData) as? UIColor {
self.accentColor = Color(uiColor)
} else {
self.accentColor = .accentColor
}
if let appearanceRawValue = UserDefaults.standard.string(forKey: "selectedAppearance"),
let appearance = Appearance(rawValue: appearanceRawValue) {
self.selectedAppearance = appearance
} else {
self.selectedAppearance = .system
}
updateAppearance()
}
private func saveAccentColor(_ color: Color) {
let uiColor = UIColor(color)
do {
let colorData = try NSKeyedArchiver.archivedData(withRootObject: uiColor, requiringSecureCoding: false)
UserDefaults.standard.set(colorData, forKey: "accentColor")
} catch {
print("Failed to save accent color: \(error.localizedDescription)")
}
}
func updateAppearance() {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
switch selectedAppearance {
case .system:
windowScene.windows.first?.overrideUserInterfaceStyle = .unspecified
case .light:
windowScene.windows.first?.overrideUserInterfaceStyle = .light
case .dark:
windowScene.windows.first?.overrideUserInterfaceStyle = .dark
}
}
}

View file

@ -1,303 +0,0 @@
//
// SettingView.swift
// Sora
//
// Created by Francesco on 18/12/24.
//
import SwiftUI
import Kingfisher
import UniformTypeIdentifiers
struct SettingsView: View {
@EnvironmentObject var settings: Settings
@State private var isDocumentPickerPresented = false
@State private var showImportSuccessAlert = false
@State private var showImportFailAlert = false
@State private var importErrorMessage = ""
@State private var miruDataToImport: MiruDataStruct?
@State private var selectedModule: ModuleStruct?
@StateObject private var libraryManager = LibraryManager.shared
@StateObject private var modulesManager = ModulesManager()
var body: some View {
NavigationView {
Form {
Section(header: Text("Interface")) {
ColorPicker("Accent Color", selection: $settings.accentColor)
HStack() {
Text("Appearance")
Picker("Appearance", selection: $settings.selectedAppearance) {
Text("System").tag(Appearance.system)
Text("Light").tag(Appearance.light)
Text("Dark").tag(Appearance.dark)
}
.pickerStyle(SegmentedPickerStyle())
}
NavigationLink(destination: SettingsIUView()) {
Text("Interface Settings")
}
NavigationLink(destination: SettingsPlayerView()) {
Text("Media Player")
}
}
Section(header: Text("External Features")) {
NavigationLink(destination: SettingsModuleView()) {
HStack {
Image(systemName: "puzzlepiece.fill")
Text("Modules")
}
}
NavigationLink(destination: SettingsStorageView()) {
HStack {
Image(systemName: "externaldrive.fill")
Text("Storage")
}
}
ForEach(modulesManager.modules.filter { $0.extractor == "dub-sub" }, id: \.name) { module in
Button(action: {
isDocumentPickerPresented = true
selectedModule = module
}) {
HStack {
Image(systemName: "tray.and.arrow.down.fill")
Text("Import Miru Bookmarks into \(module.name)")
}
}
}
}
Section(header: Text("Debug")) {
NavigationLink(destination: SettingsLogsView()) {
HStack {
Image(systemName: "doc.text.fill")
Text("Logs")
}
}
NavigationLink(destination: SettingsEditorView(modulesManager: ModulesManager())) {
HStack {
Image(systemName: "pencil.and.outline")
Text("Modules Editor")
}
}
}
Section(header: Text("Info")) {
NavigationLink(destination: AboutView()) {
Text("About")
}
NavigationLink(destination: SettingsReleasesView()) {
Text("Releases")
}
Button(action: {
if let url = URL(string: "https://github.com/cranci1/Sora") {
UIApplication.shared.open(url)
}
}) {
HStack {
Text("Sora github repo")
.foregroundColor(.primary)
Spacer()
Image(systemName: "safari")
.foregroundColor(.secondary)
}
}
Button(action: {
if let url = URL(string: "https://github.com/cranci1/Sora/issues") {
UIApplication.shared.open(url)
}
}) {
HStack {
Text("Report an issue")
.foregroundColor(.primary)
Spacer()
Image(systemName: "safari")
.foregroundColor(.secondary)
}
}
Button(action: {
if let url = URL(string: "https://discord.gg/x7hppDWFDZ") {
UIApplication.shared.open(url)
}
}) {
HStack {
Text("Join the Discord")
.foregroundColor(.primary)
Spacer()
Image(systemName: "safari")
.foregroundColor(.secondary)
}
}
}
}
.navigationTitle("Settings")
.sheet(isPresented: $isDocumentPickerPresented) {
DocumentPicker(
libraryManager: libraryManager,
onSuccess: { miruData in
miruDataToImport = miruData
if let selectedModule = selectedModule {
libraryManager.importFromMiruData(miruData, module: selectedModule)
showImportSuccessAlert = true
}
},
onFailure: { errorMessage in
importErrorMessage = errorMessage
showImportFailAlert = true
}
)
}
.alert("Data Imported!", isPresented: $showImportSuccessAlert) {
Button("OK", role: .cancel) { }
} message: {
Text("Miru bookmarks are now imported in Sora, enjoy!")
}
.alert("Import Failed", isPresented: $showImportFailAlert) {
Button("OK", role: .cancel) { }
} message: {
Text(importErrorMessage)
}
}
.navigationViewStyle(StackNavigationViewStyle())
}
}
struct DocumentPicker: UIViewControllerRepresentable {
var libraryManager: LibraryManager
var onSuccess: (MiruDataStruct) -> Void
var onFailure: (String) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(self, libraryManager: libraryManager, onSuccess: onSuccess, onFailure: onFailure)
}
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let picker = UIDocumentPickerViewController(forOpeningContentTypes: [UTType.json])
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
class Coordinator: NSObject, UIDocumentPickerDelegate {
var parent: DocumentPicker
var libraryManager: LibraryManager
var onSuccess: (MiruDataStruct) -> Void
var onFailure: (String) -> Void
init(_ parent: DocumentPicker, libraryManager: LibraryManager, onSuccess: @escaping (MiruDataStruct) -> Void, onFailure: @escaping (String) -> Void) {
self.parent = parent
self.libraryManager = libraryManager
self.onSuccess = onSuccess
self.onFailure = onFailure
}
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let selectedFileURL = urls.first else {
let errorMessage = "No file URL selected"
print(errorMessage)
Logger.shared.log(errorMessage)
onFailure(errorMessage)
return
}
guard selectedFileURL.startAccessingSecurityScopedResource() else {
let errorMessage = "Could not access the file"
print(errorMessage)
Logger.shared.log("Could not access the Miru Backup File")
onFailure(errorMessage)
return
}
defer {
selectedFileURL.stopAccessingSecurityScopedResource()
}
do {
let data = try Data(contentsOf: selectedFileURL)
var miruData = try JSONDecoder().decode(MiruDataStruct.self, from: data)
miruData.likes = miruData.likes.map { like in
var updatedLike = like
updatedLike.gogoSlug = "/series/" + like.gogoSlug
return updatedLike
}
Logger.shared.log("Imported Miru data from \(selectedFileURL)")
onSuccess(miruData)
} catch {
let errorMessage = "Failed to import Miru data: \(error.localizedDescription)"
print(errorMessage)
Logger.shared.log(errorMessage)
onFailure(errorMessage)
}
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
let errorMessage = "Document picker was closed"
print(errorMessage)
Logger.shared.log(errorMessage)
onFailure(errorMessage)
}
}
}
enum Appearance: String, CaseIterable, Identifiable {
case system, light, dark
var id: String { self.rawValue }
}
class Settings: ObservableObject {
@Published var accentColor: Color {
didSet {
saveAccentColor(accentColor)
}
}
@Published var selectedAppearance: Appearance {
didSet {
UserDefaults.standard.set(selectedAppearance.rawValue, forKey: "selectedAppearance")
updateAppearance()
}
}
init() {
if let colorData = UserDefaults.standard.data(forKey: "accentColor"),
let uiColor = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(colorData) as? UIColor {
self.accentColor = Color(uiColor)
} else {
self.accentColor = .accentColor
}
if let appearanceRawValue = UserDefaults.standard.string(forKey: "selectedAppearance"),
let appearance = Appearance(rawValue: appearanceRawValue) {
self.selectedAppearance = appearance
} else {
self.selectedAppearance = .system
}
updateAppearance()
}
private func saveAccentColor(_ color: Color) {
let uiColor = UIColor(color)
do {
let colorData = try NSKeyedArchiver.archivedData(withRootObject: uiColor, requiringSecureCoding: false)
UserDefaults.standard.set(colorData, forKey: "accentColor")
} catch {
print("Failed to save accent color: \(error.localizedDescription)")
Logger.shared.log("Failed to save accent color: \(error.localizedDescription)")
}
}
func updateAppearance() {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
switch selectedAppearance {
case .system:
windowScene.windows.first?.overrideUserInterfaceStyle = .unspecified
case .light:
windowScene.windows.first?.overrideUserInterfaceStyle = .light
case .dark:
windowScene.windows.first?.overrideUserInterfaceStyle = .dark
}
}
}

View file

@ -1,293 +0,0 @@
//
// SettingsAboutView.swift
// Sora
//
// Created by Francesco on 18/12/24.
//
import SwiftUI
import Kingfisher
struct AboutView: View {
var body: some View {
Form {
Section(footer: Text("Sora is a free open source app, under the GPLv3.0 License. You can find the entire Sora code in the github repo.")) {
HStack(alignment: .center, spacing: 10) {
KFImage(URL(string: "https://raw.githubusercontent.com/cranci1/Sora/main/Sora/Assets.xcassets/AppIcon.appiconset/180.png"))
.resizable()
.frame(width: 80, height: 80)
.clipShape(Circle())
VStack(alignment: .leading) {
Text("Sora")
.font(.largeTitle)
.fontWeight(.bold)
Text("Public beta 0.1.1")
.font(.subheadline)
.foregroundColor(.secondary)
}
}
.padding(.vertical)
}
Section(header: Text("Developer")) {
Button(action: {
if let url = URL(string: "https://github.com/cranci1") {
UIApplication.shared.open(url)
}
}) {
HStack {
KFImage(URL(string: "https://avatars.githubusercontent.com/u/100066266?v=4"))
.resizable()
.frame(width: 40, height: 40)
.clipShape(Circle())
VStack(alignment: .leading) {
Text("cranci1")
.font(.headline)
.foregroundColor(.pink)
Text("YAY it's me")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "safari")
.foregroundColor(.pink)
}
}
}
Section(header: Text("Huge thanks"), footer: Text("A huge thanks to the Miru Development team for their support and contributions to Sora. I wont ever be able to thank them enough. Thanks a lot to them and all my discord helper.")) {
HStack {
KFImage(URL(string: "https://storage.ko-fi.com/cdn/useruploads/e68c31f0-7e66-4d63-934a-0508ce443bc0_e71506-30ce-4a01-9ac3-892ffcd18b77.png"))
.resizable()
.frame(width: 40, height: 40)
.clipShape(Circle())
Text("Miru Development Team")
.font(.headline)
.foregroundColor(.green)
}
Button(action: {
if let url = URL(string: "https://github.com/bshar1865") {
UIApplication.shared.open(url)
}
}) {
HStack {
KFImage(URL(string: "https://avatars.githubusercontent.com/u/98615778?v=4"))
.resizable()
.frame(width: 40, height: 40)
.clipShape(Circle())
VStack(alignment: .leading) {
Text("MA.")
.font(.headline)
.foregroundColor(.orange)
Text("Discord Helper")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "safari")
.foregroundColor(.orange)
}
}
Button(action: {
if let url = URL(string: "https://github.com/50n50") {
UIApplication.shared.open(url)
}
}) {
HStack {
KFImage(URL(string: "https://avatars.githubusercontent.com/u/80717571?v=4"))
.resizable()
.frame(width: 40, height: 40)
.clipShape(Circle())
VStack(alignment: .leading) {
Text("50/50")
.font(.headline)
.foregroundColor(.mint)
Text("Discord Helper & Designer")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "safari")
.foregroundColor(.mint)
}
}
Button(action: {
if let url = URL(string: "https://github.com/IBH-RAD") {
UIApplication.shared.open(url)
}
}) {
HStack {
KFImage(URL(string: "https://avatars.githubusercontent.com/u/116025932?u=393be7ee3f476362b9e09d4f195ac035c5060236&v=4"))
.resizable()
.frame(width: 40, height: 40)
.clipShape(Circle())
VStack(alignment: .leading) {
Text("IBH")
.font(.headline)
.foregroundColor(.purple)
Text("Discord Helper & Bug Hunter")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "safari")
.foregroundColor(.purple)
}
}
Button(action: {
if let url = URL(string: "https://github.com/Seeike") {
UIApplication.shared.open(url)
}
}) {
HStack {
KFImage(URL(string: "https://avatars.githubusercontent.com/u/122684677?v=4"))
.resizable()
.frame(width: 40, height: 40)
.clipShape(Circle())
VStack(alignment: .leading) {
Text("Seiike")
.font(.headline)
.foregroundColor(.yellow)
Text("Discord Helper")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "safari")
.foregroundColor(.yellow)
}
}
}
Section(header: Text("Acknowledgements"), footer: Text("Thanks to the creators of this frameworks, that made Sora creation much simplier.")) {
Button(action: {
if let url = URL(string: "https://github.com/scinfu/SwiftSoup") {
UIApplication.shared.open(url)
}
}) {
HStack {
KFImage(URL(string: "https://raw.githubusercontent.com/scinfu/SwiftSoup/master/swiftsoup.png"))
.resizable()
.frame(width: 40, height: 40)
VStack(alignment: .leading) {
Text("SwiftSoup")
.font(.headline)
.foregroundColor(.red)
Text("Web scraping")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "safari")
.foregroundColor(.red)
}
}
Button(action: {
if let url = URL(string: "https://github.com/onevcat/Kingfisher") {
UIApplication.shared.open(url)
}
}) {
HStack {
KFImage(URL(string: "https://products.fileformat.com/image/swift/kingfisher/header-image.png"))
.resizable()
.frame(width: 40, height: 40)
VStack(alignment: .leading) {
Text("Kingfisher")
.font(.headline)
.foregroundColor(.blue)
Text("Images caching and loading")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "safari")
.foregroundColor(.blue)
}
}
Button(action: {
if let url = URL(string: "https://github.com/ipavlidakis/OpenCastSwift") {
UIApplication.shared.open(url)
}
}) {
HStack {
KFImage(URL(string: "https://avatars.githubusercontent.com/u/575802?v=4"))
.resizable()
.frame(width: 40, height: 40)
.clipShape(Circle())
VStack(alignment: .leading) {
Text("OpenCastSwift")
.font(.headline)
.foregroundColor(.green)
Text("Casting support")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "safari")
.foregroundColor(.green)
}
}
Button(action: {
if let url = URL(string: "https://github.com/SwiftyJSON/SwiftyJSON") {
UIApplication.shared.open(url)
}
}) {
HStack {
KFImage(URL(string: "https://avatars.githubusercontent.com/u/8858017?s=200&v=4"))
.resizable()
.frame(width: 40, height: 40)
.clipShape(Circle())
VStack(alignment: .leading) {
Text("SwiftyJSON")
.font(.headline)
.foregroundColor(.orange)
Text("Opencast dependency")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "safari")
.foregroundColor(.orange)
}
}
Button(action: {
if let url = URL(string: "https://github.com/apple/swift-protobuf") {
UIApplication.shared.open(url)
}
}) {
HStack {
KFImage(URL(string: "https://avatars.githubusercontent.com/u/10639145?s=200&v=4"))
.resizable()
.frame(width: 40, height: 40)
.clipShape(Circle())
VStack(alignment: .leading) {
Text("Swift Protobuf")
.font(.headline)
.foregroundColor(.purple)
Text("Opencast dependency")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Image(systemName: "safari")
.foregroundColor(.purple)
}
}
}
}
.navigationTitle("About")
}
}

View file

@ -1,34 +0,0 @@
//
// SettingsEditorView.swift
// Sora
//
// Created by Francesco on 03/01/25.
//
import SwiftUI
struct SettingsEditorView: View {
@ObservedObject var modulesManager: ModulesManager
@State private var jsonText: String = ""
var body: some View {
VStack {
TextEditor(text: $jsonText)
.padding()
.onAppear {
if let data = try? JSONEncoder().encode(modulesManager.modules),
let jsonString = String(data: data, encoding: .utf8) {
jsonText = jsonString
}
}
}
.navigationTitle("Editor")
.navigationBarItems(trailing: Button("Save") {
if let data = jsonText.data(using: .utf8),
let modules = try? JSONDecoder().decode([ModuleStruct].self, from: data) {
modulesManager.modules = modules
modulesManager.saveModuleData()
}
})
}
}

View file

@ -1,22 +0,0 @@
//
// SettingsIUView.swift
// Sora
//
// Created by Francesco on 18/12/24.
//
import SwiftUI
struct SettingsIUView: View {
@AppStorage("listSearch") private var isListSearchEnabled: Bool = false
var body: some View {
Form {
Section(header: Text("Search")) {
Toggle("List Search Style", isOn: $isListSearchEnabled)
.tint(.accentColor)
}
}
.navigationTitle("Interface Preference")
}
}

View file

@ -1,72 +0,0 @@
//
// SettingsLogsView.swift
// Sora
//
// Created by Francesco on 18/12/24.
//
import SwiftUI
struct SettingsLogsView: View {
@State private var logs: String = ""
var body: some View {
VStack {
ScrollView {
Text(logs)
.font(.footnote)
.foregroundColor(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
.textSelection(.enabled)
}
.navigationTitle("Logs")
.onAppear {
logs = Logger.shared.getLogs()
}
}
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Menu {
Button(action: {
UIPasteboard.general.string = logs
}) {
Label("Copy to Clipboard", systemImage: "doc.on.doc")
}
Button(role: .destructive, action: {
Logger.shared.clearLogs()
logs = Logger.shared.getLogs()
}) {
Label("Clear Logs", systemImage: "trash")
}
} label: {
Image(systemName: "ellipsis.circle")
.resizable()
.frame(width: 20, height: 20)
}
}
}
}
}
class Logger {
static let shared = Logger()
private var logs: [(message: String, timestamp: Date)] = []
private init() {}
func log(_ message: String) {
logs.append((message: message, timestamp: Date()))
}
func getLogs() -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return logs.map { "[\(dateFormatter.string(from: $0.timestamp))] \($0.message)" }
.joined(separator: "\n---\n")
}
func clearLogs() {
logs.removeAll()
}
}

View file

@ -1,139 +0,0 @@
//
// SettingsModuleView.swift
// Sora
//
// Created by Francesco on 18/12/24.
//
import SwiftUI
import Kingfisher
struct ErrorMessage: Identifiable {
var id: String { message }
let message: String
}
struct SettingsModuleView: View {
@StateObject private var modulesManager = ModulesManager()
@State private var showingAddModuleAlert = false
@State private var moduleURL = ""
@State private var errorMessage: ErrorMessage?
@State private var previusImageURLs: [String: String] = [:]
var body: some View {
VStack {
if modulesManager.isLoading {
ProgressView("Loading Modules...")
} else {
List {
ForEach(modulesManager.modules, id: \.name) { module in
HStack {
if let url = URL(string: module.iconURL) {
if previusImageURLs[module.name] != module.iconURL {
KFImage(url)
.resizable()
.frame(width: 50, height: 50)
.clipShape(Circle())
.padding(.trailing, 10)
.onAppear {
previusImageURLs[module.name] = module.iconURL
}
} else {
KFImage(url)
.resizable()
.frame(width: 50, height: 50)
.clipShape(Circle())
.padding(.trailing, 10)
}
}
VStack(alignment: .leading) {
Text(module.name)
.font(.headline)
.foregroundColor(.primary)
Text("Version: \(module.version)")
.font(.subheadline)
.foregroundColor(.secondary)
Text("Author: \(module.author.name)")
.font(.subheadline)
.foregroundColor(.secondary)
Text("Language: \(module.language)")
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
Text(module.stream)
.font(.caption)
.padding(5)
.background(Color.accentColor)
.foregroundColor(Color.primary)
.clipShape(Capsule())
}
.contextMenu {
Button(action: {
UIPasteboard.general.string = modulesManager.moduleURLs[module.name]
}) {
Label("Copy URL", systemImage: "doc.on.doc")
}
Button(role: .destructive, action: {
modulesManager.deleteModule(named: module.name)
}) {
Label("Delete", systemImage: "trash")
}
}
}
.onDelete(perform: deleteModule)
}
.navigationBarTitle("Modules")
.navigationBarItems(trailing: Button(action: {
showAddModuleAlert()
}) {
Image(systemName: "plus")
.resizable()
.frame(width: 20, height: 20)
})
.refreshable {
modulesManager.refreshModules()
}
}
}
.onAppear {
modulesManager.loadModules()
}
.alert(item: $errorMessage) { error in
Alert(title: Text("Error"), message: Text(error.message), dismissButton: .default(Text("OK")))
}
}
func showAddModuleAlert() {
let alert = UIAlertController(title: "Add Module", message: "Enter the URL of the module file", preferredStyle: .alert)
alert.addTextField { textField in
textField.placeholder = "https://real.url/module.json"
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
alert.addAction(UIAlertAction(title: "Add", style: .default, handler: { _ in
if let url = alert.textFields?.first?.text {
modulesManager.addModule(from: url) { result in
switch result {
case .success:
break
case .failure(let error):
errorMessage = ErrorMessage(message: error.localizedDescription)
Logger.shared.log(error.localizedDescription.capitalized)
}
}
}
}))
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController {
rootViewController.present(alert, animated: true, completion: nil)
}
}
func deleteModule(at offsets: IndexSet) {
offsets.forEach { index in
let module = modulesManager.modules[index]
modulesManager.deleteModule(named: module.name)
}
}
}

View file

@ -1,75 +0,0 @@
//
// SettingsPlayerView.swift
// Sora
//
// Created by Francesco on 18/12/24.
//
import SwiftUI
struct SettingsPlayerView: View {
@AppStorage("externalPlayer") private var externalPlayer: String = "Default"
@AppStorage("AlwaysLandscape") private var isAlwaysLandscape = false
@AppStorage("hideNextButton") private var isHideNextButton = false
@AppStorage("holdSpeedPlayer") private var holdSpeedPlayer: Double = 2.0
var body: some View {
Form {
Section(header: Text("Media Player"), footer: Text("The Force Landscape and HoldSpeed only work inside the default iOS player and Sora player.")) {
HStack {
Text("Media Player")
Spacer()
Menu(externalPlayer) {
Button(action: {
externalPlayer = "Default"
}) {
Label("Default", systemImage: externalPlayer == "Default" ? "checkmark" : "")
}
Button(action: {
externalPlayer = "VLC"
}) {
Label("VLC", systemImage: externalPlayer == "VLC" ? "checkmark" : "")
}
Button(action: {
externalPlayer = "OutPlayer"
}) {
Label("OutPlayer", systemImage: externalPlayer == "OutPlayer" ? "checkmark" : "")
}
Button(action: {
externalPlayer = "Infuse"
}) {
Label("Infuse", systemImage: externalPlayer == "Infuse" ? "checkmark" : "")
}
Button(action: {
externalPlayer = "nPlayer"
}) {
Label("nPlayer", systemImage: externalPlayer == "nPlayer" ? "checkmark" : "")
}
Button(action: {
externalPlayer = "Sora"
}) {
Label("Sora", systemImage: externalPlayer == "Sora" ? "checkmark" : "")
}
}
}
Toggle("Hide 'Watch Next' after 5s", isOn: $isHideNextButton)
.tint(.accentColor)
HStack {
Text("Hold Speed:")
Spacer()
Stepper(
value: $holdSpeedPlayer,
in: 0.25...2.0,
step: 0.25
) {
Text(String(format: "%.2f", holdSpeedPlayer))
}
}
}
}
.navigationTitle("Player")
}
}

View file

@ -1,42 +0,0 @@
//
// SettingsReleasesView.swift
// Sora
//
// Created by Francesco on 31/12/24.
//
import SwiftUI
struct SettingsReleasesView: View {
@State private var releases: [GitHubReleases] = []
var body: some View {
List(releases, id: \.tagName) { release in
VStack(alignment: .leading) {
Text(release.tagName)
.font(.system(size: 17))
.bold()
Text(release.body)
.font(.system(size: 14))
}
.contextMenu {
Button(action: {
if let url = URL(string: release.htmlUrl) {
UIApplication.shared.open(url)
}
}) {
Text("View on GitHub")
Image(systemName: "safari")
}
}
}
.navigationTitle("Releases")
.onAppear {
GitHubAPI.shared.fetchReleases { fetchedReleases in
if let fetchedReleases = fetchedReleases {
self.releases = fetchedReleases
}
}
}
}
}

View file

@ -1,269 +0,0 @@
//
// SettingsStorageView.swift
// Sora
//
// Created by Francesco on 19/12/24.
//
import SwiftUI
struct SettingsStorageView: View {
@State private var appSize: String = "Calculating..."
@State private var storageDetails: [(String, Double, Color)] = []
@State private var deviceStorage: (total: Int64, used: Int64) = (0, 0)
@State private var showingClearCacheAlert = false
@State private var showingClearDocumentsAlert = false
var body: some View {
List {
Section {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .center) {
Text(appSize)
.font(.system(size: 28, weight: .bold))
Text("of \(ByteCountFormatter.string(fromByteCount: deviceStorage.total, countStyle: .file))")
.foregroundColor(.secondary)
}
VStack(alignment: .leading, spacing: 4) {
GeometryReader { geometry in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.2))
.frame(height: 24)
RoundedRectangle(cornerRadius: 8)
.fill(Color.gray.opacity(0.3))
.frame(width: geometry.size.width * CGFloat(deviceStorage.used) / CGFloat(deviceStorage.total))
.frame(height: 24)
HStack(spacing: 0) {
ForEach(storageDetails, id: \.0) { detail in
RoundedRectangle(cornerRadius: 0)
.fill(detail.2)
.frame(width: geometry.size.width * CGFloat(detail.1 * 1024 * 1024) / CGFloat(deviceStorage.total))
.frame(height: 24)
}
}
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.frame(height: 24)
HStack(spacing: 16) {
ForEach(storageDetails, id: \.0) { detail in
HStack(spacing: 4) {
Circle()
.fill(detail.2)
.frame(width: 8, height: 8)
Text(detail.0)
.font(.caption)
.foregroundColor(.secondary)
}
}
HStack(spacing: 4) {
Circle()
.fill(Color.gray.opacity(0.3))
.frame(width: 8, height: 8)
Text("System")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
.padding(.vertical, 8)
}
Section {
ForEach(storageDetails, id: \.0) { detail in
HStack {
Image(systemName: categoryIcon(for: detail.0))
.foregroundColor(.white)
.frame(width: 28, height: 28)
.background(detail.2)
.clipShape(RoundedRectangle(cornerRadius: 6))
VStack(alignment: .leading, spacing: 2) {
Text(detail.0)
Text("\(detail.1, specifier: "%.2f") MB")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
Section(header: Text("Actions")) {
Button(action: { showingClearCacheAlert = true }) {
actionRow(
icon: "clock.fill",
title: "Clear Cache",
subtitle: "Free up space used by cached items",
iconColor: .accentColor
)
}
Button(action: { showingClearDocumentsAlert = true }) {
actionRow(
icon: "doc.fill",
title: "Clear Documents",
subtitle: "Check and remove unnecessary files",
iconColor: .accentColor
)
}
}
}
.navigationTitle("Storage")
.onAppear {
calculateAppSize()
getDeviceStorage()
}
.alert("Clear Cache", isPresented: $showingClearCacheAlert) {
Button("Cancel", role: .cancel) { }
Button("Clear", role: .destructive) {
clearCache()
}
} message: {
Text("Are you sure you want to clear the cache? This action cannot be undone.")
}
.alert("Clear Documents", isPresented: $showingClearDocumentsAlert) {
Button("Cancel", role: .cancel) { }
Button("Clear", role: .destructive) {
clearDocuments()
}
} message: {
Text("Are you sure you want to clear all documents? This action cannot be undone.")
}
}
private func clearCache() {
if let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
do {
let fileURLs = try FileManager.default.contentsOfDirectory(
at: cacheURL,
includingPropertiesForKeys: nil,
options: .skipsHiddenFiles
)
for fileURL in fileURLs {
try FileManager.default.removeItem(at: fileURL)
}
calculateAppSize()
getDeviceStorage()
Logger.shared.log("Cleared Cache")
} catch {
print("Error clearing cache: \(error)")
Logger.shared.log("Error clearing cache: \(error)")
}
}
}
private func clearDocuments() {
if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
do {
let fileURLs = try FileManager.default.contentsOfDirectory(
at: documentsURL,
includingPropertiesForKeys: nil,
options: .skipsHiddenFiles
)
for fileURL in fileURLs {
try FileManager.default.removeItem(at: fileURL)
}
calculateAppSize()
getDeviceStorage()
Logger.shared.log("Cleared Documents")
} catch {
print("Error clearing documents: \(error)")
Logger.shared.log("Error clearing documents: \(error)")
}
}
}
private func getDeviceStorage() {
do {
let fileURL = URL(fileURLWithPath: NSHomeDirectory() as String)
let values = try fileURL.resourceValues(forKeys: [.volumeTotalCapacityKey, .volumeAvailableCapacityKey])
if let total = values.volumeTotalCapacity,
let available = values.volumeAvailableCapacity {
deviceStorage.total = Int64(total)
deviceStorage.used = Int64(total - available)
}
} catch {
print("Error getting device storage: \(error)")
Logger.shared.log("Error getting device storage: \(error)")
}
}
private func getTotalAppBytes() -> Int64 {
return Int64(totalSize() * 1024 * 1024)
}
private func actionRow(icon: String, title: String, subtitle: String, iconColor: Color) -> some View {
HStack {
Image(systemName: icon)
.foregroundColor(.white)
.frame(width: 28, height: 28)
.background(iconColor)
.clipShape(RoundedRectangle(cornerRadius: 6))
VStack(alignment: .leading, spacing: 2) {
Text(title)
Text(subtitle)
.font(.caption)
.foregroundColor(.secondary)
}
}
}
private func categoryIcon(for category: String) -> String {
switch category {
case "Documents":
return "doc.fill"
case "Cache":
return "clock.fill"
case "Temporary":
return "trash.fill"
default:
return "questionmark"
}
}
private func calculateAppSize() {
let cacheSize = getDirectorySize(url: FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!)
let documentsSize = getDirectorySize(url: FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!)
let tmpSize = getDirectorySize(url: FileManager.default.temporaryDirectory)
let totalSize = cacheSize + documentsSize + tmpSize
self.appSize = ByteCountFormatter.string(fromByteCount: Int64(totalSize), countStyle: .file)
self.storageDetails = [
("Documents", documentsSize / 1024 / 1024, .green),
("Cache", cacheSize / 1024 / 1024, .orange),
("Temporary", tmpSize / 1024 / 1024, .red)
]
}
private func getDirectorySize(url: URL) -> Double {
var size: Double = 0
if let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [.fileSizeKey], options: [], errorHandler: nil) {
for case let fileURL as URL in enumerator {
do {
let resourceValues = try fileURL.resourceValues(forKeys: [.fileSizeKey])
size += Double(resourceValues.fileSize ?? 0)
} catch {
print("Error calculating size for file \(fileURL): \(error)")
Logger.shared.log("Error calculating size for file \(fileURL): \(error)")
}
}
}
return size
}
private func totalSize() -> Double {
return storageDetails.reduce(0) { $0 + $1.1 }
}
}

View file

@ -1,39 +0,0 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
WORKING_LOCATION="$(pwd)"
APPLICATION_NAME=Sora
if [ ! -d "build" ]; then
mkdir build
fi
cd build
xcodebuild -project "$WORKING_LOCATION/$APPLICATION_NAME.xcodeproj" \
-scheme "$APPLICATION_NAME" \
-configuration Release \
-derivedDataPath "$WORKING_LOCATION/build/DerivedDataApp" \
-destination 'platform=macOS,variant=Mac Catalyst' \
clean build \
CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGN_ENTITLEMENTS="" CODE_SIGNING_ALLOWED="NO"
DD_APP_PATH="$WORKING_LOCATION/build/DerivedDataApp/Build/Products/Release-maccatalyst/$APPLICATION_NAME.app"
TARGET_APP="$WORKING_LOCATION/build/$APPLICATION_NAME.app"
if [ -e "$TARGET_APP" ]; then
rm -rf "$TARGET_APP"
fi
cp -r "$DD_APP_PATH" "$TARGET_APP"
codesign --remove "$TARGET_APP"
if [ -e "$TARGET_APP/_CodeSignature" ]; then
rm -rf "$TARGET_APP/_CodeSignature"
fi
zip -vr "$APPLICATION_NAME-catalyst.zip" "$APPLICATION_NAME.app"
rm -rf "$APPLICATION_NAME.app"