diff --git a/Sora/Info.plist b/Sora/Info.plist index e942154..df1d517 100644 --- a/Sora/Info.plist +++ b/Sora/Info.plist @@ -2,8 +2,6 @@ - NSCameraUsageDescription - Sora may requires access to your device's camera. BGTaskSchedulerPermittedIdentifiers $(PRODUCT_BUNDLE_IDENTIFIER) diff --git a/Sora/SoraApp.swift b/Sora/SoraApp.swift index a945639..4073e20 100644 --- a/Sora/SoraApp.swift +++ b/Sora/SoraApp.swift @@ -29,7 +29,11 @@ struct SoraApp: App { } } .onOpenURL { url in - handleURL(url) + if let params = url.queryParameters, params["code"] != nil { + Self.handleRedirect(url: url) + } else { + handleURL(url) + } } } } @@ -52,4 +56,20 @@ struct SoraApp: App { Logger.shared.log("Failed to present module addition view: No window scene found", type: "Error") } } + + static func handleRedirect(url: URL) { + guard let params = url.queryParameters, + let code = params["code"] else { + Logger.shared.log("Failed to extract authorization code") + return + } + + AniListToken.exchangeAuthorizationCodeForToken(code: code) { success in + if success { + Logger.shared.log("Token exchange successful") + } else { + Logger.shared.log("Token exchange failed", type: "Error") + } + } + } } diff --git a/Sora/Tracking Services/AniList/Auth/Anilist-Login.swift b/Sora/Tracking Services/AniList/Auth/Anilist-Login.swift new file mode 100644 index 0000000..de958b1 --- /dev/null +++ b/Sora/Tracking Services/AniList/Auth/Anilist-Login.swift @@ -0,0 +1,34 @@ +// +// Login.swift +// Ryu +// +// Created by Francesco on 08/08/24. +// + +import UIKit + +class AniListLogin { + static let clientID = "19551" + static let redirectURI = "sora://anilist" + + static let authorizationEndpoint = "https://anilist.co/api/v2/oauth/authorize" + + static func authenticate() { + let urlString = "\(authorizationEndpoint)?client_id=\(clientID)&redirect_uri=\(redirectURI)&response_type=code" + guard let url = URL(string: urlString) else { + return + } + + if UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:]) { success in + if success { + Logger.shared.log("Safari opened successfully", type: "Debug") + } else { + Logger.shared.log("Failed to open Safari", type: "Error") + } + } + } else { + Logger.shared.log("Cannot open URL", type: "Error") + } + } +} diff --git a/Sora/Tracking Services/AniList/Auth/Anilist-Token.swift b/Sora/Tracking Services/AniList/Auth/Anilist-Token.swift new file mode 100644 index 0000000..68bc232 --- /dev/null +++ b/Sora/Tracking Services/AniList/Auth/Anilist-Token.swift @@ -0,0 +1,88 @@ +// +// Token.swift +// Ryu +// +// Created by Francesco on 08/08/24. +// + +import UIKit +import Security + +class AniListToken { + static let clientID = "19551" + static let clientSecret = "fk8EgkyFbXk95TbPwLYQLaiMaNIryMpDBwJsPXoX" + static let redirectURI = "sora://anilist" + + static let tokenEndpoint = "https://anilist.co/api/v2/oauth/token" + static let serviceName = "me.cranci.sora.AniListToken" + static let accountName = "AniListAccessToken" + + static func saveTokenToKeychain(token: String) -> Bool { + let tokenData = token.data(using: .utf8)! + + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: accountName + ] + SecItemDelete(deleteQuery as CFDictionary) + + let addQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: accountName, + kSecValueData as String: tokenData + ] + + let status = SecItemAdd(addQuery as CFDictionary, nil) + return status == errSecSuccess + } + + static func exchangeAuthorizationCodeForToken(code: String, completion: @escaping (Bool) -> Void) { + Logger.shared.log("Exchanging authorization code for access token...") + + guard let url = URL(string: tokenEndpoint) else { + Logger.shared.log("Invalid token endpoint URL", type: "Error") + completion(false) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + + let bodyString = "grant_type=authorization_code&client_id=\(clientID)&client_secret=\(clientSecret)&redirect_uri=\(redirectURI)&code=\(code)" + request.httpBody = bodyString.data(using: .utf8) + + let task = URLSession.shared.dataTask(with: request) { data, response, error in + if let error = error { + Logger.shared.log("Error: \(error.localizedDescription)", type: "Error") + completion(false) + return + } + + guard let data = data else { + Logger.shared.log("No data received", type: "Error") + completion(false) + return + } + + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let accessToken = json["access_token"] as? String { + let success = saveTokenToKeychain(token: accessToken) + completion(success) + } else { + Logger.shared.log("Unexpected response: \(json)", type: "Error") + completion(false) + } + } + } catch { + Logger.shared.log("Failed to parse JSON: \(error.localizedDescription)", type: "Error") + completion(false) + } + } + + task.resume() + } +} diff --git a/Sora/Tracking Services/AniList/HomePage/AniList-Seasonal.swift b/Sora/Tracking Services/AniList/HomePage/AniList-Seasonal.swift deleted file mode 100644 index 341615f..0000000 --- a/Sora/Tracking Services/AniList/HomePage/AniList-Seasonal.swift +++ /dev/null @@ -1,117 +0,0 @@ -// -// AniList-Seasonal.swift -// Sora -// -// Created by Francesco on 09/02/25. -// - -import Foundation - -class AnilistServiceSeasonalAnime { - func fetchSeasonalAnime(completion: @escaping ([AniListItem]?) -> Void) { - let currentDate = Date() - let calendar = Calendar.current - let year = calendar.component(.year, from: currentDate) - let month = calendar.component(.month, from: currentDate) - - let season: String - switch month { - case 1...3: - season = "WINTER" - case 4...6: - season = "SPRING" - case 7...9: - season = "SUMMER" - default: - season = "FALL" - } - - let query = """ - query { - Page(page: 1, perPage: 100) { - media(season: \(season), seasonYear: \(year), type: ANIME, isAdult: false) { - id - title { - romaji - english - native - } - coverImage { - large - } - } - } - } - """ - - guard let url = URL(string: "https://graphql.anilist.co") else { - print("Invalid URL") - completion(nil) - return - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - - let parameters: [String: Any] = ["query": query] - do { - request.httpBody = try JSONSerialization.data(withJSONObject: parameters, options: []) - } catch { - print("Error encoding JSON: \(error.localizedDescription)") - completion(nil) - return - } - - let task = URLSession.custom.dataTask(with: request) { data, response, error in - DispatchQueue.main.async { - if let error = error { - print("Error fetching seasonal anime: \(error.localizedDescription)") - completion(nil) - return - } - - guard let data = data else { - print("No data returned") - completion(nil) - return - } - - do { - if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let dataObject = json["data"] as? [String: Any], - let page = dataObject["Page"] as? [String: Any], - let media = page["media"] as? [[String: Any]] { - - let seasonalAnime: [AniListItem] = media.compactMap { item -> AniListItem? in - guard let id = item["id"] as? Int, - let titleData = item["title"] as? [String: Any], - let romaji = titleData["romaji"] as? String, - let english = titleData["english"] as? String?, - let native = titleData["native"] as? String?, - let coverImageData = item["coverImage"] as? [String: Any], - let largeImageUrl = coverImageData["large"] as? String, - URL(string: largeImageUrl) != nil else { - return nil - } - - return AniListItem( - id: id, - title: AniListTitle(romaji: romaji, english: english, native: native), - coverImage: AniListCoverImage(large: largeImageUrl) - ) - } - completion(seasonalAnime) - } else { - print("Error parsing JSON or missing expected fields") - completion(nil) - } - } catch { - print("Error decoding JSON: \(error.localizedDescription)") - completion(nil) - } - } - } - task.resume() - } -} diff --git a/Sora/Tracking Services/AniList/HomePage/AniList-Trending.swift b/Sora/Tracking Services/AniList/HomePage/AniList-Trending.swift deleted file mode 100644 index b02e970..0000000 --- a/Sora/Tracking Services/AniList/HomePage/AniList-Trending.swift +++ /dev/null @@ -1,93 +0,0 @@ -// -// AniList-Trending.swift -// Sora -// -// Created by Francesco on 09/02/25. -// - -import Foundation - -class AnilistServiceTrendingAnime { - func fetchTrendingAnime(completion: @escaping ([AniListItem]?) -> Void) { - let query = """ - query { - Page(page: 1, perPage: 100) { - media(sort: TRENDING_DESC, type: ANIME, isAdult: false) { - id - title { - romaji - english - native - } - coverImage { - large - } - } - } - } - """ - guard let url = URL(string: "https://graphql.anilist.co") else { - print("Invalid URL") - completion(nil) - return - } - - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.addValue("application/json", forHTTPHeaderField: "Content-Type") - let parameters: [String: Any] = ["query": query] - do { - request.httpBody = try JSONSerialization.data(withJSONObject: parameters, options: []) - } catch { - print("Error encoding JSON: \(error.localizedDescription)") - completion(nil) - return - } - - let task = URLSession.custom.dataTask(with: request) { data, response, error in - DispatchQueue.main.async { - if let error = error { - print("Error fetching trending anime: \(error.localizedDescription)") - completion(nil) - return - } - guard let data = data else { - print("No data returned") - completion(nil) - return - } - do { - if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let dataObject = json["data"] as? [String: Any], - let page = dataObject["Page"] as? [String: Any], - let media = page["media"] as? [[String: Any]] { - - let trendingAnime: [AniListItem] = media.compactMap { item in - guard let id = item["id"] as? Int, - let titleData = item["title"] as? [String: Any], - let romaji = titleData["romaji"] as? String, - let coverImageData = item["coverImage"] as? [String: Any], - let largeImageUrl = coverImageData["large"] as? String else { - return nil - } - - return AniListItem( - id: id, - title: AniListTitle(romaji: romaji, english: titleData["english"] as? String, native: titleData["native"] as? String), - coverImage: AniListCoverImage(large: largeImageUrl) - ) - } - completion(trendingAnime) - } else { - print("Error parsing JSON or missing expected fields") - completion(nil) - } - } catch { - print("Error decoding JSON: \(error.localizedDescription)") - completion(nil) - } - } - } - task.resume() - } -} diff --git a/Sora/Tracking Services/AniList/HomePage/DetailsView/AniList-DetailsView.swift b/Sora/Tracking Services/AniList/HomePage/DetailsView/AniList-DetailsView.swift deleted file mode 100644 index 4f7a9d4..0000000 --- a/Sora/Tracking Services/AniList/HomePage/DetailsView/AniList-DetailsView.swift +++ /dev/null @@ -1,316 +0,0 @@ -// -// AniList-DetailsView.swift -// Sora -// -// Created by Francesco on 11/02/25. -// - -import SwiftUI -import Kingfisher - -struct AniListDetailsView: View { - let animeID: Int - @StateObject private var viewModel: AniListDetailsViewModel - - init(animeID: Int) { - self.animeID = animeID - _viewModel = StateObject(wrappedValue: AniListDetailsViewModel(animeID: animeID)) - } - - var body: some View { - ScrollView { - VStack(spacing: 16) { - if viewModel.isLoading { - ProgressView() - .padding() - } else if let media = viewModel.mediaInfo { - MediaHeaderView(media: media) - Divider() - MediaDetailsScrollView(media: media) - Divider() - SynopsisView(synopsis: media["description"] as? String) - Divider() - CharactersView(characters: media["characters"] as? [String: Any]) - Divider() - ScoreDistributionView(stats: media["stats"] as? [String: Any]) - } else { - Text("Failed to load media details.") - .padding() - } - } - } - .navigationBarTitle("", displayMode: .inline) - .navigationViewStyle(StackNavigationViewStyle()) - .onAppear { - viewModel.fetchDetails() - } - } -} - -class AniListDetailsViewModel: ObservableObject { - @Published var mediaInfo: [String: AnyHashable]? - @Published var isLoading: Bool = true - - let animeID: Int - - init(animeID: Int) { - self.animeID = animeID - } - - func fetchDetails() { - AnilistServiceMediaInfo.fetchAnimeDetails(animeID: animeID) { result in - DispatchQueue.main.async { - switch result { - case .success(let media): - var convertedMedia: [String: AnyHashable] = [:] - for (key, value) in media { - if let value = value as? AnyHashable { - convertedMedia[key] = value - } - } - self.mediaInfo = convertedMedia - case .failure(let error): - print("Error: \(error)") - } - self.isLoading = false - } - } - } -} - -struct MediaHeaderView: View { - let media: [String: Any] - - var body: some View { - HStack(alignment: .top, spacing: 16) { - if let coverDict = media["coverImage"] as? [String: Any], - let posterURLString = coverDict["extraLarge"] as? String, - let posterURL = URL(string: posterURLString) { - KFImage(posterURL) - .placeholder { - RoundedRectangle(cornerRadius: 10) - .fill(Color.gray.opacity(0.3)) - .frame(width: 150, height: 225) - .shimmering() - } - .resizable() - .aspectRatio(2/3, contentMode: .fill) - .cornerRadius(10) - .frame(width: 150, height: 225) - } - - VStack(alignment: .leading) { - if let titleDict = media["title"] as? [String: Any], - let userPreferred = titleDict["english"] as? String { - Text(userPreferred) - .font(.system(size: 17)) - .fontWeight(.bold) - .onLongPressGesture { - UIPasteboard.general.string = userPreferred - DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill")) - } - } - - if let titleDict = media["title"] as? [String: Any], - let userPreferred = titleDict["romaji"] as? String { - Text(userPreferred) - .font(.system(size: 13)) - .foregroundColor(.secondary) - } - - if let titleDict = media["title"] as? [String: Any], - let userPreferred = titleDict["native"] as? String { - Text(userPreferred) - .font(.system(size: 13)) - .foregroundColor(.secondary) - } - } - - Spacer() - } - .padding() - } -} - -struct MediaDetailsScrollView: View { - let media: [String: Any] - - var body: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 4) { - if let type = media["type"] as? String { - MediaDetailItem(title: "Type", value: type) - Divider() - } - if let episodes = media["episodes"] as? Int { - MediaDetailItem(title: "Episodes", value: "\(episodes)") - Divider() - } - if let duration = media["duration"] as? Int { - MediaDetailItem(title: "Length", value: "\(duration) mins") - Divider() - } - if let format = media["format"] as? String { - MediaDetailItem(title: "Format", value: format) - Divider() - } - if let status = media["status"] as? String { - MediaDetailItem(title: "Status", value: status) - Divider() - } - if let season = media["season"] as? String { - MediaDetailItem(title: "Season", value: season) - Divider() - } - if let startDate = media["startDate"] as? [String: Any], - let year = startDate["year"] as? Int, - let month = startDate["month"] as? Int, - let day = startDate["day"] as? Int { - MediaDetailItem(title: "Start Date", value: "\(year)-\(month)-\(day)") - Divider() - } - if let endDate = media["endDate"] as? [String: Any], - let year = endDate["year"] as? Int, - let month = endDate["month"] as? Int, - let day = endDate["day"] as? Int { - MediaDetailItem(title: "End Date", value: "\(year)-\(month)-\(day)") - } - } - } - } -} - -struct SynopsisView: View { - let synopsis: String? - - var body: some View { - if let synopsis = synopsis { - Text(synopsis.strippedHTML) - .padding(.horizontal) - .foregroundColor(.secondary) - .font(.system(size: 14)) - } else { - EmptyView() - } - } -} - -struct CharactersView: View { - let characters: [String: Any]? - - var body: some View { - if let charactersDict = characters, - let edges = charactersDict["edges"] as? [[String: Any]] { - VStack(alignment: .leading, spacing: 8) { - Text("Characters") - .font(.headline) - .padding(.horizontal) - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(Array(edges.prefix(15).enumerated()), id: \.offset) { _, edge in - if let node = edge["node"] as? [String: Any], - let nameDict = node["name"] as? [String: Any], - let fullName = nameDict["full"] as? String, - let imageDict = node["image"] as? [String: Any], - let imageUrlStr = imageDict["large"] as? String, - let imageUrl = URL(string: imageUrlStr) { - CharacterItemView(imageUrl: imageUrl, name: fullName) - } - } - } - .padding(.horizontal) - } - } - } else { - EmptyView() - } - } -} - -struct CharacterItemView: View { - let imageUrl: URL - let name: String - - var body: some View { - VStack { - KFImage(imageUrl) - .placeholder { - Circle() - .fill(Color.gray.opacity(0.3)) - .frame(width: 90, height: 90) - .shimmering() - } - .resizable() - .scaledToFill() - .frame(width: 90, height: 90) - .clipShape(Circle()) - Text(name) - .font(.caption) - .lineLimit(1) - } - .frame(width: 105, height: 110) - } -} - -struct ScoreDistributionView: View { - let stats: [String: Any]? - - @State private var barHeights: [CGFloat] = [] - - var body: some View { - if let stats = stats, - let scoreDistribution = stats["scoreDistribution"] as? [[String: AnyHashable]] { - - let maxValue: Int = scoreDistribution.compactMap { $0["amount"] as? Int }.max() ?? 1 - - let calculatedHeights = scoreDistribution.map { dataPoint -> CGFloat in - guard let amount = dataPoint["amount"] as? Int else { return 0 } - return CGFloat(amount) / CGFloat(maxValue) * 100 - } - - VStack { - Text("Score Distribution") - .font(.headline) - HStack(alignment: .bottom) { - ForEach(Array(scoreDistribution.enumerated()), id: \.offset) { index, dataPoint in - if let score = dataPoint["score"] as? Int { - VStack { - Rectangle() - .fill(Color.accentColor) - .frame(width: 20, height: calculatedHeights[index]) - Text("\(score)") - .font(.caption) - } - } - } - } - } - .frame(maxWidth: .infinity) - .padding(.horizontal) - .onAppear { - barHeights = calculatedHeights - } - .onChange(of: scoreDistribution) { _ in - barHeights = calculatedHeights - } - } else { - EmptyView() - } - } -} - -struct MediaDetailItem: View { - var title: String - var value: String - - var body: some View { - VStack { - Text(value) - .font(.system(size: 17)) - Text(title) - .font(.system(size: 13)) - .foregroundColor(.secondary) - } - .padding(.horizontal) - } -} diff --git a/Sora/Tracking Services/AniList/MediaInfo/AniList-MediaInfo.swift b/Sora/Tracking Services/AniList/MediaInfo/AniList-MediaInfo.swift deleted file mode 100644 index 92b34b5..0000000 --- a/Sora/Tracking Services/AniList/MediaInfo/AniList-MediaInfo.swift +++ /dev/null @@ -1,135 +0,0 @@ -// -// AniList-MediaInfo.swift -// Sora -// -// Created by Francesco on 11/02/25. -// - -import Foundation - -class AnilistServiceMediaInfo { - static func fetchAnimeDetails(animeID: Int, completion: @escaping (Result<[String: Any], Error>) -> Void) { - let query = """ - query { - Media(id: \(animeID), type: ANIME) { - id - idMal - title { - romaji - english - native - userPreferred - } - type - format - status - description - startDate { - year - month - day - } - endDate { - year - month - day - } - season - episodes - duration - countryOfOrigin - isLicensed - source - hashtag - trailer { - id - site - } - updatedAt - coverImage { - extraLarge - } - bannerImage - genres - popularity - tags { - id - name - } - relations { - nodes { - id - coverImage { extraLarge } - title { userPreferred }, - mediaListEntry { status } - } - } - characters { - edges { - node { - name { - full - } - image { - large - } - } - role - voiceActors { - name { - first - last - native - } - } - } - } - siteUrl - stats { - scoreDistribution { - score - amount - } - } - airingSchedule(notYetAired: true) { - nodes { - airingAt - episode - } - } - } - } - """ - - let apiUrl = URL(string: "https://graphql.anilist.co")! - - var request = URLRequest(url: apiUrl) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.httpBody = try? JSONSerialization.data(withJSONObject: ["query": query], options: []) - - URLSession.custom.dataTask(with: request) { data, response, error in - if let error = error { - completion(.failure(error)) - return - } - - guard let data = data else { - completion(.failure(NSError(domain: "AnimeService", 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] { - completion(.success(media)) - } else { - completion(.failure(NSError(domain: "AnimeService", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid response format"]))) - } - } catch { - completion(.failure(error)) - } - }.resume() - } -} diff --git a/Sora/Utils/ContinueWatching/ContinueWatchingManager.swift b/Sora/Utils/ContinueWatching/ContinueWatchingManager.swift index 0c6cc0e..f8b6452 100644 --- a/Sora/Utils/ContinueWatching/ContinueWatchingManager.swift +++ b/Sora/Utils/ContinueWatching/ContinueWatchingManager.swift @@ -14,6 +14,11 @@ class ContinueWatchingManager { private init() {} func save(item: ContinueWatchingItem) { + if item.progress >= 0.9 { + remove(item: item) + return + } + var items = fetchItems() if let index = items.firstIndex(where: { $0.streamUrl == item.streamUrl && $0.episodeNumber == item.episodeNumber }) { items[index] = item diff --git a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift new file mode 100644 index 0000000..eda6f2a --- /dev/null +++ b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift @@ -0,0 +1,226 @@ +// +// JSContext+Extensions.swift +// Sora +// +// Created by Hamzo on 19/03/25. +// + +import JavaScriptCore + +extension JSContext { + func setupConsoleLogging() { + let consoleObject = JSValue(newObjectIn: self) + + let consoleLogFunction: @convention(block) (String) -> Void = { message in + Logger.shared.log(message, type: "Debug") + } + consoleObject?.setObject(consoleLogFunction, forKeyedSubscript: "log" as NSString) + + let consoleErrorFunction: @convention(block) (String) -> Void = { message in + Logger.shared.log(message, type: "Error") + } + consoleObject?.setObject(consoleErrorFunction, forKeyedSubscript: "error" as NSString) + + self.setObject(consoleObject, forKeyedSubscript: "console" as NSString) + + let logFunction: @convention(block) (String) -> Void = { message in + Logger.shared.log("JavaScript log: \(message)", type: "Debug") + } + self.setObject(logFunction, forKeyedSubscript: "log" as NSString) + } + + func setupNativeFetch() { + let fetchNativeFunction: @convention(block) (String, [String: String]?, JSValue, JSValue) -> Void = { urlString, headers, resolve, reject in + guard let url = URL(string: urlString) else { + Logger.shared.log("Invalid URL", type: "Error") + reject.call(withArguments: ["Invalid URL"]) + return + } + var request = URLRequest(url: url) + if let headers = headers { + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + } + let task = URLSession.custom.dataTask(with: request) { data, _, error in + if let error = error { + Logger.shared.log("Network error in fetchNativeFunction: \(error.localizedDescription)", type: "Error") + reject.call(withArguments: [error.localizedDescription]) + return + } + guard let data = data else { + Logger.shared.log("No data in response", type: "Error") + reject.call(withArguments: ["No data"]) + return + } + if let text = String(data: data, encoding: .utf8) { + resolve.call(withArguments: [text]) + } else { + Logger.shared.log("Unable to decode data to text", type: "Error") + reject.call(withArguments: ["Unable to decode data"]) + } + } + task.resume() + } + self.setObject(fetchNativeFunction, forKeyedSubscript: "fetchNative" as NSString) + + let fetchDefinition = """ + function fetch(url, headers) { + return new Promise(function(resolve, reject) { + fetchNative(url, headers, resolve, reject); + }); + } + """ + self.evaluateScript(fetchDefinition) + } + + func setupFetchV2() { + let fetchV2NativeFunction: @convention(block) (String, [String: String]?, String?, String?, JSValue, JSValue) -> Void = { urlString, headers, method, body, resolve, reject in + guard let url = URL(string: urlString) else { + Logger.shared.log("Invalid URL", type: "Error") + reject.call(withArguments: ["Invalid URL"]) + return + } + + let httpMethod = method ?? "GET" + var request = URLRequest(url: url) + request.httpMethod = httpMethod + + Logger.shared.log("FetchV2 Request: URL=\(url), Method=\(httpMethod), Body=\(body ?? "nil")", type: "Debug") + + if httpMethod == "GET", let body = body, !body.isEmpty, body != "null", body != "undefined" { + Logger.shared.log("GET request must not have a body", type: "Error") + reject.call(withArguments: ["GET request must not have a body"]) + return + } + + if httpMethod != "GET", let body = body, !body.isEmpty, body != "null", body != "undefined" { + request.httpBody = body.data(using: .utf8) + } + + + if let headers = headers { + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + } + + let task = URLSession.custom.downloadTask(with: request) { tempFileURL, response, error in + if let error = error { + Logger.shared.log("Network error in fetchV2NativeFunction: \(error.localizedDescription)", type: "Error") + reject.call(withArguments: [error.localizedDescription]) + return + } + + guard let tempFileURL = tempFileURL else { + Logger.shared.log("No data in response", type: "Error") + reject.call(withArguments: ["No data"]) + return + } + + do { + let data = try Data(contentsOf: tempFileURL) + + if data.count > 10_000_000 { + Logger.shared.log("Response exceeds maximum size", type: "Error") + reject.call(withArguments: ["Response exceeds maximum size"]) + return + } + + if let text = String(data: data, encoding: .utf8) { + resolve.call(withArguments: [text]) + } else { + Logger.shared.log("Unable to decode data to text", type: "Error") + reject.call(withArguments: ["Unable to decode data"]) + } + + } catch { + Logger.shared.log("Error reading downloaded file: \(error.localizedDescription)", type: "Error") + reject.call(withArguments: ["Error reading downloaded file"]) + } + } + task.resume() + } + + + self.setObject(fetchV2NativeFunction, forKeyedSubscript: "fetchV2Native" as NSString) + + let fetchv2Definition = """ + function fetchv2(url, headers = {}, method = "GET", body = null) { + if (method === "GET") { + return new Promise(function(resolve, reject) { + fetchV2Native(url, headers, method, null, function(rawText) { // Pass `null` explicitly + const responseObj = { + _data: rawText, + text: function() { + return Promise.resolve(this._data); + }, + json: function() { + try { + return Promise.resolve(JSON.parse(this._data)); + } catch (e) { + return Promise.reject("JSON parse error: " + e.message); + } + } + }; + resolve(responseObj); + }, reject); + }); + } + + // Ensure body is properly serialized + const processedBody = body ? JSON.stringify(body) : null; + + return new Promise(function(resolve, reject) { + fetchV2Native(url, headers, method, processedBody, function(rawText) { + const responseObj = { + _data: rawText, + text: function() { + return Promise.resolve(this._data); + }, + json: function() { + try { + return Promise.resolve(JSON.parse(this._data)); + } catch (e) { + return Promise.reject("JSON parse error: " + e.message); + } + } + }; + resolve(responseObj); + }, reject); + }); + } + + """ + self.evaluateScript(fetchv2Definition) + } + + func setupBase64Functions() { + let btoaFunction: @convention(block) (String) -> String? = { data in + guard let data = data.data(using: .utf8) else { + Logger.shared.log("btoa: Failed to encode input as UTF-8", type: "Error") + return nil + } + return data.base64EncodedString() + } + + let atobFunction: @convention(block) (String) -> String? = { base64String in + guard let data = Data(base64Encoded: base64String) else { + Logger.shared.log("atob: Invalid base64 input", type: "Error") + return nil + } + + return String(data: data, encoding: .utf8) + } + + self.setObject(btoaFunction, forKeyedSubscript: "btoa" as NSString) + self.setObject(atobFunction, forKeyedSubscript: "atob" as NSString) + } + + func setupJavaScriptEnvironment() { + setupConsoleLogging() + setupNativeFetch() + setupFetchV2() + setupBase64Functions() + } +} diff --git a/Sora/Utils/Extensions/URL.swift b/Sora/Utils/Extensions/URL.swift new file mode 100644 index 0000000..3dc88f2 --- /dev/null +++ b/Sora/Utils/Extensions/URL.swift @@ -0,0 +1,20 @@ +// +// URL.swift +// Sulfur +// +// Created by Francesco on 23/03/25. +// + +import Foundation + +extension URL { + var queryParameters: [String: String]? { + guard let components = URLComponents(url: self, resolvingAgainstBaseURL: true), + let queryItems = components.queryItems else { return nil } + var params = [String: String]() + for queryItem in queryItems { + params[queryItem.name] = queryItem.value + } + return params + } +} diff --git a/Sora/Utils/Extensions/URLSession.swift b/Sora/Utils/Extensions/URLSession.swift index cb1862d..544b084 100644 --- a/Sora/Utils/Extensions/URLSession.swift +++ b/Sora/Utils/Extensions/URLSession.swift @@ -9,40 +9,38 @@ 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/125.0.0.0 Safari/537.36", - "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; 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" + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.2569.45", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.2478.89", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_1_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.1 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_0_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.0 Safari/605.1.15", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 15.1; rv:128.0) Gecko/20100101 Firefox/128.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 15.0; rv:127.0) Gecko/20100101 Firefox/127.0", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0", + "Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0", + "Mozilla/5.0 (Linux; Android 15; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6782.112 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6782.112 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.3 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Android 15; Mobile; rv:128.0) Gecko/128.0 Firefox/128.0", + "Mozilla/5.0 (Android 15; Mobile; rv:127.0) Gecko/127.0 Firefox/127.0" ] - static let randomUserAgent: String = { + static var randomUserAgent: String = { userAgents.randomElement() ?? userAgents[0] }() static let custom: URLSession = { let configuration = URLSessionConfiguration.default - configuration.httpAdditionalHeaders = [ - "User-Agent": randomUserAgent - ] + configuration.httpAdditionalHeaders = ["User-Agent": randomUserAgent] return URLSession(configuration: configuration) }() } diff --git a/Sora/Utils/JSLoader/JSController.swift b/Sora/Utils/JSLoader/JSController.swift index 9861b2a..a0a220b 100644 --- a/Sora/Utils/JSLoader/JSController.swift +++ b/Sora/Utils/JSLoader/JSController.swift @@ -16,66 +16,16 @@ class JSController: ObservableObject { } private func setupContext() { - let consoleObject = JSValue(newObjectIn: context) - let consoleLogFunction: @convention(block) (String) -> Void = { message in - Logger.shared.log(message, type: "Debug") - } - consoleObject?.setObject(consoleLogFunction, forKeyedSubscript: "log" as NSString) - context.setObject(consoleObject, forKeyedSubscript: "console" as NSString) - - let logFunction: @convention(block) (String) -> Void = { message in - Logger.shared.log("JavaScript log: \(message)", type: "Debug") - } - context.setObject(logFunction, forKeyedSubscript: "log" as NSString) - - let fetchNativeFunction: @convention(block) (String, [String: String]?, JSValue, JSValue) -> Void = { urlString, headers, resolve, reject in - guard let url = URL(string: urlString) else { - Logger.shared.log("Invalid URL", type: "Error") - reject.call(withArguments: ["Invalid URL"]) - return - } - var request = URLRequest(url: url) - if let headers = headers { - for (key, value) in headers { - request.setValue(value, forHTTPHeaderField: key) - } - } - let task = URLSession.custom.dataTask(with: request) { data, _, error in - if let error = error { - Logger.shared.log("Network error in fetchNativeFunction: \(error.localizedDescription)", type: "Error") - reject.call(withArguments: [error.localizedDescription]) - return - } - guard let data = data else { - Logger.shared.log("No data in response", type: "Error") - reject.call(withArguments: ["No data"]) - return - } - if let text = String(data: data, encoding: .utf8) { - resolve.call(withArguments: [text]) - } else { - Logger.shared.log("Unable to decode data to text", type: "Error") - reject.call(withArguments: ["Unable to decode data"]) - } - } - task.resume() - } - context.setObject(fetchNativeFunction, forKeyedSubscript: "fetchNative" as NSString) - - let fetchDefinition = """ - function fetch(url, headers) { - return new Promise(function(resolve, reject) { - fetchNative(url, headers, resolve, reject); - }); - } - """ - context.evaluateScript(fetchDefinition) + context.setupJavaScriptEnvironment() } func loadScript(_ script: String) { context = JSContext() setupContext() context.evaluateScript(script) + if let exception = context.exception { + Logger.shared.log("Error loading script: \(exception)", type: "Error") + } } func fetchSearchResults(keyword: String, module: ScrapingModule, completion: @escaping ([SearchItem]) -> Void) { @@ -249,10 +199,13 @@ class JSController: ObservableObject { 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 ?? "" + let resultItems = array.compactMap { item -> SearchItem? in + guard let title = item["title"] as? String, + let imageUrl = item["image"] as? String, + let href = item["href"] as? String else { + Logger.shared.log("Missing or invalid data in search result item: \(item)", type: "Error") + return nil + } return SearchItem(title: title, imageUrl: imageUrl, href: href) } diff --git a/Sora/Utils/Logger/Logger.swift b/Sora/Utils/Logger/Logger.swift index eedb87a..70ad048 100644 --- a/Sora/Utils/Logger/Logger.swift +++ b/Sora/Utils/Logger/Logger.swift @@ -31,6 +31,8 @@ class Logger { let entry = LogEntry(message: message, type: type, timestamp: Date()) logs.append(entry) saveLogToFile(entry) + + debugLog(entry) } @@ -38,7 +40,7 @@ class Logger { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "dd-MM HH:mm:ss" return logs.map { "[\(dateFormatter.string(from: $0.timestamp))] [\($0.type)] \($0.message)" } - .joined(separator: "\n----\n") + .joined(separator: "\n----\n") } func clearLogs() { @@ -64,4 +66,13 @@ class Logger { } } } -} + + /// Prints log messages to the Xcode console only in DEBUG mode + private func debugLog(_ entry: LogEntry) { + #if DEBUG + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "dd-MM HH:mm:ss" + let formattedMessage = "[\(dateFormatter.string(from: entry.timestamp))] [\(entry.type)] \(entry.message)" + print(formattedMessage) + #endif + }} diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Components/Double+Extension.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Components/Double+Extension.swift index 801cc7b..dd7d7bd 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/Components/Double+Extension.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/Components/Double+Extension.swift @@ -20,11 +20,21 @@ extension Double { } extension BinaryFloatingPoint { - func asTimeString(style: DateComponentsFormatter.UnitsStyle) -> String { - let formatter = DateComponentsFormatter() - formatter.allowedUnits = [.minute, .second] - formatter.unitsStyle = style - formatter.zeroFormattingBehavior = .pad - return formatter.string(from: TimeInterval(self)) ?? "" + func asTimeString(style: TimeStringStyle, showHours: Bool = false) -> String { + let totalSeconds = Int(self) + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + let seconds = totalSeconds % 60 + + if showHours || hours > 0 { + return String(format: "%02d:%02d:%02d", hours, minutes, seconds) + } else { + return String(format: "%02d:%02d", minutes, seconds) + } } } + +enum TimeStringStyle { + case positional + case standard +} \ No newline at end of file diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift index 30a94fc..e8a3ca8 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift @@ -42,10 +42,13 @@ struct MusicProgressSlider: View { } HStack { - Text(value.asTimeString(style: .positional)) + // Determine if we should show hours based on the total duration. + let shouldShowHours = inRange.upperBound >= 3600 + Text(value.asTimeString(style: .positional, showHours: shouldShowHours)) Spacer(minLength: 0) - Text("-" + (inRange.upperBound - value).asTimeString(style: .positional)) + Text("-" + (inRange.upperBound - value).asTimeString(style: .positional, showHours: shouldShowHours)) } + .font(.system(size: 12)) .foregroundColor(isActive ? fillColor : emptyColor) } diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index b9d00aa..f6e1f71 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -42,13 +42,17 @@ class CustomMediaPlayerViewController: UIViewController { var isWatchNextVisible: Bool = false var lastDuration: Double = 0.0 var watchNextButtonAppearedAt: Double? - var subtitleForegroundColor: String = "white" var subtitleBackgroundEnabled: Bool = true var subtitleFontSize: Double = 20.0 var subtitleShadowRadius: Double = 1.0 var subtitlesLoader = VTTSubtitlesLoader() + var subtitlesEnabled: Bool = true { + didSet { + subtitleLabel.isHidden = !subtitlesEnabled + } + } var playerViewController: AVPlayerViewController! var controlsContainerView: UIView! @@ -136,11 +140,13 @@ class CustomMediaPlayerViewController: UIViewController { loadSubtitleSettings() setupPlayerViewController() setupControls() + setupSkipAndDismissGestures() + addInvisibleControlOverlays() setupSubtitleLabel() setupDismissButton() - setupMenuButton() - setupSpeedButton() setupQualityButton() + setupSpeedButton() + setupMenuButton() setupSkip85Button() setupWatchNextButton() addTimeObserver() @@ -346,6 +352,135 @@ class CustomMediaPlayerViewController: UIViewController { ]) } + func addInvisibleControlOverlays() { + let playPauseOverlay = UIButton(type: .custom) + playPauseOverlay.backgroundColor = .clear + playPauseOverlay.addTarget(self, action: #selector(togglePlayPause), for: .touchUpInside) + view.addSubview(playPauseOverlay) + playPauseOverlay.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + playPauseOverlay.centerXAnchor.constraint(equalTo: playPauseButton.centerXAnchor), + playPauseOverlay.centerYAnchor.constraint(equalTo: playPauseButton.centerYAnchor), + playPauseOverlay.widthAnchor.constraint(equalTo: playPauseButton.widthAnchor, constant: 20), + playPauseOverlay.heightAnchor.constraint(equalTo: playPauseButton.heightAnchor, constant: 20) + ]) + + let backwardOverlay = UIButton(type: .custom) + backwardOverlay.backgroundColor = .clear + backwardOverlay.addTarget(self, action: #selector(seekBackward), for: .touchUpInside) + view.addSubview(backwardOverlay) + backwardOverlay.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + backwardOverlay.centerXAnchor.constraint(equalTo: backwardButton.centerXAnchor), + backwardOverlay.centerYAnchor.constraint(equalTo: backwardButton.centerYAnchor), + backwardOverlay.widthAnchor.constraint(equalTo: backwardButton.widthAnchor, constant: 20), + backwardOverlay.heightAnchor.constraint(equalTo: backwardButton.heightAnchor, constant: 20) + ]) + + let forwardOverlay = UIButton(type: .custom) + forwardOverlay.backgroundColor = .clear + forwardOverlay.addTarget(self, action: #selector(seekForward), for: .touchUpInside) + view.addSubview(forwardOverlay) + forwardOverlay.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + forwardOverlay.centerXAnchor.constraint(equalTo: forwardButton.centerXAnchor), + forwardOverlay.centerYAnchor.constraint(equalTo: forwardButton.centerYAnchor), + forwardOverlay.widthAnchor.constraint(equalTo: forwardButton.widthAnchor, constant: 20), + forwardOverlay.heightAnchor.constraint(equalTo: forwardButton.heightAnchor, constant: 20) + ]) + } + + func setupSkipAndDismissGestures() { + let doubleTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:))) + doubleTapGesture.numberOfTapsRequired = 2 + view.addGestureRecognizer(doubleTapGesture) + + if let gestures = view.gestureRecognizers { + for gesture in gestures { + if let tapGesture = gesture as? UITapGestureRecognizer, tapGesture.numberOfTapsRequired == 1 { + tapGesture.require(toFail: doubleTapGesture) + } + } + } + + let swipeDownGesture = UISwipeGestureRecognizer(target: self, action: #selector(handleSwipeDown(_:))) + swipeDownGesture.direction = .down + view.addGestureRecognizer(swipeDownGesture) + } + + func showSkipFeedback(direction: String) { + let diameter: CGFloat = 600 + + if let existingFeedback = view.viewWithTag(999) { + existingFeedback.layer.removeAllAnimations() + existingFeedback.removeFromSuperview() + } + + let circleView = UIView() + circleView.backgroundColor = UIColor.white.withAlphaComponent(0.0) + circleView.layer.cornerRadius = diameter / 2 + circleView.clipsToBounds = true + circleView.translatesAutoresizingMaskIntoConstraints = false + circleView.isUserInteractionEnabled = false + circleView.tag = 999 + + let iconName = (direction == "forward") ? "goforward" : "gobackward" + let imageView = UIImageView(image: UIImage(systemName: iconName)) + imageView.tintColor = .black + imageView.contentMode = .scaleAspectFit + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.alpha = 0.8 + + circleView.addSubview(imageView) + + if direction == "forward" { + NSLayoutConstraint.activate([ + imageView.centerYAnchor.constraint(equalTo: circleView.centerYAnchor), + imageView.centerXAnchor.constraint(equalTo: circleView.leadingAnchor, constant: diameter / 4), + imageView.widthAnchor.constraint(equalToConstant: 100), + imageView.heightAnchor.constraint(equalToConstant: 100) + ]) + } else { + NSLayoutConstraint.activate([ + imageView.centerYAnchor.constraint(equalTo: circleView.centerYAnchor), + imageView.centerXAnchor.constraint(equalTo: circleView.trailingAnchor, constant: -diameter / 4), + imageView.widthAnchor.constraint(equalToConstant: 100), + imageView.heightAnchor.constraint(equalToConstant: 100) + ]) + } + + view.addSubview(circleView) + + if direction == "forward" { + NSLayoutConstraint.activate([ + circleView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + circleView.centerXAnchor.constraint(equalTo: view.trailingAnchor), + circleView.widthAnchor.constraint(equalToConstant: diameter), + circleView.heightAnchor.constraint(equalToConstant: diameter) + ]) + } else { + NSLayoutConstraint.activate([ + circleView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + circleView.centerXAnchor.constraint(equalTo: view.leadingAnchor), + circleView.widthAnchor.constraint(equalToConstant: diameter), + circleView.heightAnchor.constraint(equalToConstant: diameter) + ]) + } + + UIView.animate(withDuration: 0.2, animations: { + circleView.backgroundColor = UIColor.white.withAlphaComponent(0.5) + imageView.alpha = 0.8 + }) { _ in + UIView.animate(withDuration: 0.5, delay: 0.5, options: [], animations: { + circleView.backgroundColor = UIColor.white.withAlphaComponent(0.0) + imageView.alpha = 0.0 + }, completion: { _ in + circleView.removeFromSuperview() + imageView.removeFromSuperview() + }) + } + } + func setupSubtitleLabel() { subtitleLabel = UILabel() subtitleLabel.textAlignment = .center @@ -392,20 +527,20 @@ class CustomMediaPlayerViewController: UIViewController { menuButton = UIButton(type: .system) menuButton.setImage(UIImage(systemName: "text.bubble"), for: .normal) menuButton.tintColor = .white - + if let subtitlesURL = subtitlesURL, !subtitlesURL.isEmpty { menuButton.showsMenuAsPrimaryAction = true menuButton.menu = buildOptionsMenu() } else { menuButton.isHidden = true } - + controlsContainerView.addSubview(menuButton) menuButton.translatesAutoresizingMaskIntoConstraints = false - guard let sliderView = sliderHostingController?.view else { return } + NSLayoutConstraint.activate([ - menuButton.bottomAnchor.constraint(equalTo: sliderView.topAnchor), - menuButton.trailingAnchor.constraint(equalTo: sliderView.trailingAnchor), + menuButton.topAnchor.constraint(equalTo: controlsContainerView.topAnchor, constant: 20), + menuButton.trailingAnchor.constraint(equalTo: speedButton.leadingAnchor, constant: -20), menuButton.widthAnchor.constraint(equalToConstant: 40), menuButton.heightAnchor.constraint(equalToConstant: 40) ]) @@ -415,30 +550,19 @@ class CustomMediaPlayerViewController: UIViewController { speedButton = UIButton(type: .system) speedButton.setImage(UIImage(systemName: "speedometer"), for: .normal) speedButton.tintColor = .white - speedButton.showsMenuAsPrimaryAction = true speedButton.menu = speedChangerMenu() - + controlsContainerView.addSubview(speedButton) speedButton.translatesAutoresizingMaskIntoConstraints = false - - guard let sliderView = sliderHostingController?.view else { return } - - if menuButton.isHidden { - NSLayoutConstraint.activate([ - speedButton.bottomAnchor.constraint(equalTo: controlsContainerView.bottomAnchor, constant: -50), - speedButton.trailingAnchor.constraint(equalTo: sliderView.trailingAnchor), - speedButton.widthAnchor.constraint(equalToConstant: 40), - speedButton.heightAnchor.constraint(equalToConstant: 40) - ]) - } else { - NSLayoutConstraint.activate([ - speedButton.bottomAnchor.constraint(equalTo: controlsContainerView.bottomAnchor, constant: -50), - speedButton.trailingAnchor.constraint(equalTo: menuButton.leadingAnchor), - speedButton.widthAnchor.constraint(equalToConstant: 40), - speedButton.heightAnchor.constraint(equalToConstant: 40) - ]) - } + + NSLayoutConstraint.activate([ + // Middle + speedButton.topAnchor.constraint(equalTo: controlsContainerView.topAnchor, constant: 20), + speedButton.trailingAnchor.constraint(equalTo: qualityButton.leadingAnchor, constant: -20), + speedButton.widthAnchor.constraint(equalToConstant: 40), + speedButton.heightAnchor.constraint(equalToConstant: 40) + ]) } func setupWatchNextButton() { @@ -464,8 +588,8 @@ class CustomMediaPlayerViewController: UIViewController { ] watchNextButtonControlsConstraints = [ - watchNextButton.trailingAnchor.constraint(equalTo: qualityButton.leadingAnchor), - watchNextButton.bottomAnchor.constraint(equalTo: skip85Button.bottomAnchor), + watchNextButton.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor), + watchNextButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5), watchNextButton.heightAnchor.constraint(equalToConstant: 50), watchNextButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 120) ] @@ -502,17 +626,18 @@ class CustomMediaPlayerViewController: UIViewController { qualityButton.showsMenuAsPrimaryAction = true qualityButton.menu = qualitySelectionMenu() qualityButton.isHidden = true - + controlsContainerView.addSubview(qualityButton) qualityButton.translatesAutoresizingMaskIntoConstraints = false - + NSLayoutConstraint.activate([ - qualityButton.bottomAnchor.constraint(equalTo: controlsContainerView.bottomAnchor, constant: -50), - qualityButton.trailingAnchor.constraint(equalTo: speedButton.leadingAnchor), + qualityButton.topAnchor.constraint(equalTo: controlsContainerView.topAnchor, constant: 20), + qualityButton.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -20), qualityButton.widthAnchor.constraint(equalToConstant: 40), qualityButton.heightAnchor.constraint(equalToConstant: 40) ]) } + func updateSubtitleLabelAppearance() { subtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize)) @@ -558,13 +683,13 @@ class CustomMediaPlayerViewController: UIViewController { UserDefaults.standard.set(self.currentTimeVal, forKey: "lastPlayedTime_\(self.fullUrl)") UserDefaults.standard.set(self.duration, forKey: "totalTime_\(self.fullUrl)") - if let currentCue = self.subtitlesLoader.cues.first(where: { self.currentTimeVal >= $0.startTime && self.currentTimeVal <= $0.endTime }) { + if self.subtitlesEnabled, + let currentCue = self.subtitlesLoader.cues.first(where: { self.currentTimeVal >= $0.startTime && self.currentTimeVal <= $0.endTime }) { self.subtitleLabel.text = currentCue.text.strippedHTML } else { self.subtitleLabel.text = "" } - // ORIGINAL PROGRESS BAR CODE: DispatchQueue.main.async { self.sliderHostingController?.rootView = MusicProgressSlider( value: Binding(get: { max(0, min(self.sliderViewModel.sliderValue, self.duration)) }, @@ -584,20 +709,16 @@ class CustomMediaPlayerViewController: UIViewController { ) } - // Watch Next Button Logic: - let hideNext = UserDefaults.standard.bool(forKey: "hideNextButton") let isNearEnd = (self.duration - self.currentTimeVal) <= (self.duration * 0.10) && self.currentTimeVal != self.duration && self.showWatchNextButton && self.duration != 0 if isNearEnd { - // First appearance: show the button in its normal position. if !self.isWatchNextVisible { self.isWatchNextVisible = true self.watchNextButtonAppearedAt = self.currentTimeVal - // Choose constraints based on current controls visibility. if self.isControlsVisible { NSLayoutConstraint.deactivate(self.watchNextButtonNormalConstraints) NSLayoutConstraint.activate(self.watchNextButtonControlsConstraints) @@ -605,7 +726,6 @@ class CustomMediaPlayerViewController: UIViewController { NSLayoutConstraint.deactivate(self.watchNextButtonControlsConstraints) NSLayoutConstraint.activate(self.watchNextButtonNormalConstraints) } - // Soft fade-in. self.watchNextButton.isHidden = false self.watchNextButton.alpha = 0.0 UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseInOut, animations: { @@ -613,23 +733,19 @@ class CustomMediaPlayerViewController: UIViewController { }, completion: nil) } - // When 5 seconds have elapsed from when the button first appeared: if let appearedAt = self.watchNextButtonAppearedAt, (self.currentTimeVal - appearedAt) >= 5, !self.isWatchNextRepositioned { - // Fade out the button first (even if controls are visible). UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseInOut, animations: { self.watchNextButton.alpha = 0.0 }, completion: { _ in self.watchNextButton.isHidden = true - // Then lock it to the controls-attached constraints. NSLayoutConstraint.deactivate(self.watchNextButtonNormalConstraints) NSLayoutConstraint.activate(self.watchNextButtonControlsConstraints) self.isWatchNextRepositioned = true }) } } else { - // Not near end: reset the watch-next button state. self.watchNextButtonAppearedAt = nil self.isWatchNextVisible = false self.isWatchNextRepositioned = false @@ -646,7 +762,6 @@ class CustomMediaPlayerViewController: UIViewController { func repositionWatchNextButton() { self.isWatchNextRepositioned = true - // Update constraints so the button is now attached next to the playback controls. UIView.animate(withDuration: 0.3, animations: { NSLayoutConstraint.deactivate(self.watchNextButtonNormalConstraints) NSLayoutConstraint.activate(self.watchNextButtonControlsConstraints) @@ -674,7 +789,6 @@ class CustomMediaPlayerViewController: UIViewController { self.skip85Button.alpha = self.isControlsVisible ? 0.8 : 0 if self.isControlsVisible { - // Always use the controls-attached constraints. NSLayoutConstraint.deactivate(self.watchNextButtonNormalConstraints) NSLayoutConstraint.activate(self.watchNextButtonControlsConstraints) if self.isWatchNextRepositioned || self.isWatchNextVisible { @@ -684,7 +798,6 @@ class CustomMediaPlayerViewController: UIViewController { }) } } else { - // When controls are hidden: if !self.isWatchNextRepositioned && self.isWatchNextVisible { NSLayoutConstraint.deactivate(self.watchNextButtonControlsConstraints) NSLayoutConstraint.activate(self.watchNextButtonNormalConstraints) @@ -733,8 +846,31 @@ class CustomMediaPlayerViewController: UIViewController { player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) } + @objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) { + let tapLocation = gesture.location(in: view) + if tapLocation.x < view.bounds.width / 2 { + seekBackward() + showSkipFeedback(direction: "backward") + } else { + seekForward() + showSkipFeedback(direction: "forward") + } + } + + @objc func handleSwipeDown(_ gesture: UISwipeGestureRecognizer) { + dismiss(animated: true, completion: nil) + } + @objc func togglePlayPause() { if isPlaying { + if !isControlsVisible { + isControlsVisible = true + UIView.animate(withDuration: 0.5) { + self.controlsContainerView.alpha = 1.0 + self.skip85Button.alpha = 0.8 + self.view.layoutIfNeeded() + } + } player.pause() playPauseButton.image = UIImage(systemName: "play.fill") } else { @@ -949,6 +1085,11 @@ class CustomMediaPlayerViewController: UIViewController { var menuElements: [UIMenuElement] = [] if let subURL = subtitlesURL, !subURL.isEmpty { + let subtitlesToggleAction = UIAction(title: "Toggle Subtitles") { [weak self] _ in + guard let self = self else { return } + self.subtitlesEnabled.toggle() + } + let foregroundActions = [ UIAction(title: "White") { _ in SubtitleSettingsManager.shared.update { settings in settings.foregroundColor = "white" } @@ -1063,7 +1204,7 @@ class CustomMediaPlayerViewController: UIViewController { ] let paddingMenu = UIMenu(title: "Bottom Padding", children: paddingActions) - let subtitleOptionsMenu = UIMenu(title: "Subtitle Options", children: [colorMenu, fontSizeMenu, shadowMenu, backgroundMenu, paddingMenu]) + let subtitleOptionsMenu = UIMenu(title: "Subtitle Options", children: [subtitlesToggleAction, colorMenu, fontSizeMenu, shadowMenu, backgroundMenu, paddingMenu]) menuElements = [subtitleOptionsMenu] } diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/VTTSubtitlesLoader.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/VTTSubtitlesLoader.swift index 37b98d4..d457d7a 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/VTTSubtitlesLoader.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/VTTSubtitlesLoader.swift @@ -29,7 +29,7 @@ class VTTSubtitlesLoader: ObservableObject { let format = determineSubtitleFormat(from: url) - URLSession.custom.dataTask(with: url) { data, _, error in + URLSession.shared.dataTask(with: url) { data, _, error in guard let data = data, let content = String(data: data, encoding: .utf8), error == nil else { return } diff --git a/Sora/Utils/Modules/ModuleAdditionSettingsView.swift b/Sora/Utils/Modules/ModuleAdditionSettingsView.swift index c9a38fa..b2580e2 100644 --- a/Sora/Utils/Modules/ModuleAdditionSettingsView.swift +++ b/Sora/Utils/Modules/ModuleAdditionSettingsView.swift @@ -146,22 +146,22 @@ struct ModuleAdditionSettingsView: View { errorMessage = nil Task { - do { - guard let url = URL(string: moduleUrl) else { - DispatchQueue.main.async { - self.errorMessage = "Invalid URL" - self.isLoading = false - } - return + guard let url = URL(string: moduleUrl) else { + await MainActor.run { + self.errorMessage = "Invalid URL" + self.isLoading = false } + return + } + do { let (data, _) = try await URLSession.custom.data(from: url) let metadata = try JSONDecoder().decode(ModuleMetadata.self, from: data) - DispatchQueue.main.async { + await MainActor.run { self.moduleMetadata = metadata self.isLoading = false } } catch { - DispatchQueue.main.async { + await MainActor.run { self.errorMessage = "Failed to fetch module: \(error.localizedDescription)" self.isLoading = false } @@ -174,13 +174,13 @@ struct ModuleAdditionSettingsView: View { Task { do { let _ = try await moduleManager.addModule(metadataUrl: moduleUrl) - DispatchQueue.main.async { + await MainActor.run { isLoading = false DropManager.shared.showDrop(title: "Module Added", subtitle: "click it to select it", duration: 2.0, icon: UIImage(systemName:"gear.badge.checkmark")) self.presentationMode.wrappedValue.dismiss() } } catch { - DispatchQueue.main.async { + await MainActor.run { isLoading = false if (error as NSError).domain == "Module already exists" { errorMessage = "Module already exists" diff --git a/Sora/Views/LibraryView/LibraryView.swift b/Sora/Views/LibraryView/LibraryView.swift index 877f38b..ade2c46 100644 --- a/Sora/Views/LibraryView/LibraryView.swift +++ b/Sora/Views/LibraryView/LibraryView.swift @@ -24,6 +24,26 @@ struct LibraryView: View { GridItem(.adaptive(minimum: 150), spacing: 12) ] + private var columnsCount: Int { + if UIDevice.current.userInterfaceIdiom == .pad { + let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height + return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait + } else { + return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait + } + } + + private var cellWidth: CGFloat { + let keyWindow = UIApplication.shared.connectedScenes + .compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow }) } + .first + let safeAreaInsets = keyWindow?.safeAreaInsets ?? .zero + let safeWidth = UIScreen.main.bounds.width - safeAreaInsets.left - safeAreaInsets.right + let totalSpacing: CGFloat = 16 * CGFloat(columnsCount + 1) + let availableWidth = safeWidth - totalSpacing + return availableWidth / CGFloat(columnsCount) + } + var body: some View { NavigationView { ScrollView { @@ -76,10 +96,6 @@ struct LibraryView: View { .frame(maxWidth: .infinity) } else { LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 12), count: columnsCount), spacing: 12) { - let totalSpacing: CGFloat = 16 * CGFloat(columnsCount + 1) - let availableWidth = UIScreen.main.bounds.width - totalSpacing - let cellWidth = availableWidth / CGFloat(columnsCount) - ForEach(libraryManager.bookmarks) { item in if let module = moduleManager.modules.first(where: { $0.id.uuidString == item.moduleId }) { NavigationLink(destination: MediaInfoView(title: item.title, imageUrl: item.imageUrl, href: item.href, module: module)) { diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 02a8499..6e1af48 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -250,7 +250,7 @@ struct MediaInfoView: View { } }, onMarkAllPrevious: { - for ep2 in seasons[selectedSeason] { + for ep2 in seasons[selectedSeason] where ep2.number < ep.number { let href = ep2.href UserDefaults.standard.set(99999999.0, forKey: "lastPlayedTime_\(href)") UserDefaults.standard.set(99999999.0, forKey: "totalTime_\(href)") @@ -296,7 +296,7 @@ struct MediaInfoView: View { UserDefaults.standard.set(99999999.0, forKey: "totalTime_\(href)") } refreshTrigger.toggle() - Logger.shared.log("Marked \(ep.number - 1) episodes watched within anime \"\(title)\".", type: "General") + Logger.shared.log("Marked \(ep.number - 1) episodes watched within series \"\(title)\".", type: "General") } ) .id(refreshTrigger) @@ -626,9 +626,15 @@ struct MediaInfoView: View { UIApplication.shared.open(url, options: [:], completionHandler: nil) Logger.shared.log("Opening external app with scheme: \(url)", type: "General") } else { + guard let url = URL(string: url) else { + Logger.shared.log("Invalid stream URL: \(url)", type: "Error") + DropManager.shared.showDrop(title: "Error", subtitle: "Invalid stream URL", duration: 2.0, icon: UIImage(systemName: "xmark.circle")) + return + } + let customMediaPlayer = CustomMediaPlayerViewController( module: module, - urlString: url, + urlString: url.absoluteString, fullUrl: fullURL, title: title, episodeNumber: selectedEpisodeNumber, @@ -644,6 +650,9 @@ struct MediaInfoView: View { if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let rootVC = windowScene.windows.first?.rootViewController { findTopViewController.findViewController(rootVC).present(customMediaPlayer, animated: true, completion: nil) + } else { + Logger.shared.log("Failed to find root view controller", type: "Error") + DropManager.shared.showDrop(title: "Error", subtitle: "Failed to present player", duration: 2.0, icon: UIImage(systemName: "xmark.circle")) } } } diff --git a/Sora/Views/SearchView.swift b/Sora/Views/SearchView.swift index 85802ba..cfd739d 100644 --- a/Sora/Views/SearchView.swift +++ b/Sora/Views/SearchView.swift @@ -19,7 +19,7 @@ struct SearchView: View { @AppStorage("selectedModuleId") private var selectedModuleId: String? @AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2 @AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4 - + @StateObject private var jsController = JSController() @EnvironmentObject var moduleManager: ModuleManager @Environment(\.verticalSizeClass) var verticalSizeClass @@ -30,6 +30,7 @@ struct SearchView: View { @State private var searchText = "" @State private var hasNoResults = false @State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape + @State private var isModuleSelectorPresented = false private var selectedModule: ScrapingModule? { guard let id = selectedModuleId else { return nil } @@ -63,12 +64,11 @@ struct SearchView: View { let availableWidth = safeWidth - totalSpacing return availableWidth / CGFloat(columnsCount) } - + var body: some View { NavigationView { ScrollView { let columnsCount = determineColumns() - VStack(spacing: 0) { HStack { SearchBar(text: $searchText, onSearchButtonClicked: performSearch) @@ -164,38 +164,44 @@ struct SearchView: View { .navigationBarTitleDisplayMode(.large) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { - HStack(spacing: 4) { - 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") + Menu { + ForEach(getModuleLanguageGroups(), id: \.self) { language in + Menu(language) { + ForEach(getModulesForLanguage(language), id: \.id) { 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) + if module.id.uuidString == selectedModuleId { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } } } } } - } label: { - Image(systemName: "chevron.up.chevron.down") + } + } label: { + HStack(spacing: 4) { + if let selectedModule = selectedModule { + Text(selectedModule.metadata.sourceName) + .font(.headline) + .foregroundColor(.secondary) + } else { + Text("Select Module") + .font(.headline) + .foregroundColor(.accentColor) + } + Image(systemName: "chevron.down") .foregroundColor(.secondary) } } - .id("moduleMenuHStack") .fixedSize() } } @@ -267,6 +273,41 @@ struct SearchView: View { return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait } } + + private func cleanLanguageName(_ language: String?) -> String { + guard let language = language else { return "Unknown" } + + let cleaned = language.replacingOccurrences( + of: "\\s*\\([^\\)]*\\)", + with: "", + options: .regularExpression + ).trimmingCharacters(in: .whitespaces) + + return cleaned.isEmpty ? "Unknown" : cleaned + } + + private func getModulesByLanguage() -> [String: [ScrapingModule]] { + var result = [String: [ScrapingModule]]() + + for module in moduleManager.modules { + let language = cleanLanguageName(module.metadata.language) + if result[language] == nil { + result[language] = [module] + } else { + result[language]?.append(module) + } + } + + return result + } + + private func getModuleLanguageGroups() -> [String] { + return getModulesByLanguage().keys.sorted() + } + + private func getModulesForLanguage(_ language: String) -> [ScrapingModule] { + return getModulesByLanguage()[language] ?? [] + } } struct SearchBar: View { diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift index ed0f83e..154d1d2 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift @@ -14,9 +14,13 @@ struct SettingsViewGeneral: View { @AppStorage("analyticsEnabled") private var analyticsEnabled: Bool = false @AppStorage("multiThreads") private var multiThreadsEnabled: Bool = false @AppStorage("metadataProviders") private var metadataProviders: String = "AniList" + @AppStorage("CustomDNSProvider") private var customDNSProvider: String = "Cloudflare" + @AppStorage("customPrimaryDNS") private var customPrimaryDNS: String = "" + @AppStorage("customSecondaryDNS") private var customSecondaryDNS: String = "" @AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2 @AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4 + private let customDNSProviderList = ["Cloudflare", "Google", "OpenDNS", "Quad9", "AdGuard", "CleanBrowsing", "ControlD", "Custom"] private let metadataProvidersList = ["AniList"] @EnvironmentObject var settings: Settings @@ -24,7 +28,7 @@ struct SettingsViewGeneral: View { Form { Section(header: Text("Interface")) { ColorPicker("Accent Color", selection: $settings.accentColor) - HStack() { + HStack { Text("Appearance") Picker("Appearance", selection: $settings.selectedAppearance) { Text("System").tag(Appearance.system) @@ -40,18 +44,10 @@ struct SettingsViewGeneral: View { Text("Episodes Range") Spacer() Menu { - Button(action: { episodeChunkSize = 25 }) { - Text("25") - } - Button(action: { episodeChunkSize = 50 }) { - Text("50") - } - Button(action: { episodeChunkSize = 75 }) { - Text("75") - } - Button(action: { episodeChunkSize = 100 }) { - Text("100") - } + Button(action: { episodeChunkSize = 25 }) { Text("25") } + Button(action: { episodeChunkSize = 50 }) { Text("50") } + Button(action: { episodeChunkSize = 75 }) { Text("75") } + Button(action: { episodeChunkSize = 100 }) { Text("100") } } label: { Text("\(episodeChunkSize)") } @@ -63,36 +59,24 @@ struct SettingsViewGeneral: View { Spacer() Menu(metadataProviders) { ForEach(metadataProvidersList, id: \.self) { provider in - Button(action: { - metadataProviders = provider - }) { + Button(action: { metadataProviders = provider }) { Text(provider) } } } - } } - //Section(header: Text("Downloads"), footer: Text("Note that the modules will be replaced only if there is a different version string inside the JSON file.")) { - // Toggle("Multi Threads conversion", isOn: $multiThreadsEnabled) - // .tint(.accentColor) - //} - Section(header: Text("Media Grid Layout"), footer: Text("Adjust the number of media items per row in portrait and landscape modes.")) { HStack { if UIDevice.current.userInterfaceIdiom == .pad { Picker("Portrait Columns", selection: $mediaColumnsPortrait) { - ForEach(1..<6) { i in - Text("\(i)").tag(i) - } + ForEach(1..<6) { i in Text("\(i)").tag(i) } } .pickerStyle(MenuPickerStyle()) } else { Picker("Portrait Columns", selection: $mediaColumnsPortrait) { - ForEach(1..<5) { i in - Text("\(i)").tag(i) - } + ForEach(1..<5) { i in Text("\(i)").tag(i) } } .pickerStyle(MenuPickerStyle()) } @@ -100,16 +84,12 @@ struct SettingsViewGeneral: View { HStack { if UIDevice.current.userInterfaceIdiom == .pad { Picker("Landscape Columns", selection: $mediaColumnsLandscape) { - ForEach(2..<9) { i in - Text("\(i)").tag(i) - } + ForEach(2..<9) { i in Text("\(i)").tag(i) } } .pickerStyle(MenuPickerStyle()) } else { Picker("Landscape Columns", selection: $mediaColumnsLandscape) { - ForEach(2..<6) { i in - Text("\(i)").tag(i) - } + ForEach(2..<6) { i in Text("\(i)").tag(i) } } .pickerStyle(MenuPickerStyle()) } @@ -121,7 +101,7 @@ struct SettingsViewGeneral: View { .tint(.accentColor) } - Section(header: Text("Analytics"), footer: Text("Allow Sora to collect anonymous data to improve the app. No personal information is collected. This can be disabled at any time.\n\n Information collected: \n- App version\n- Device model\n- Module Name/Version\n- Error Messages\n- Title of Watched Content")) { + Section(header: Text("Advanced"), footer: Text("Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time.")) { Toggle("Enable Analytics", isOn: $analyticsEnabled) .tint(.accentColor) } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift index c37ecd2..2792468 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift @@ -58,15 +58,13 @@ struct SettingsViewPlayer: View { } } } - Section(header: Text("Skip Settings")) { - // Normal skip + Section(header: Text("Skip Settings"), footer : Text("Double tapping the screen on it's sides will skip with the short tap setting.")) { HStack { Text("Tap Skip:") Spacer() Stepper("\(Int(skipIncrement))s", value: $skipIncrement, in: 5...300, step: 5) } - // Long-press skip HStack { Text("Long press Skip:") Spacer() diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift new file mode 100644 index 0000000..6727051 --- /dev/null +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift @@ -0,0 +1,203 @@ +// +// SettingsViewTrackers.swift +// Sora +// +// Created by Francesco on 23/03/25. +// + +import SwiftUI +import Security +import Kingfisher + +struct SettingsViewTrackers: View { + @State private var status: String = "You are not logged in" + @State private var isLoggedIn: Bool = false + @State private var username: String = "" + @State private var isLoading: Bool = false + @State private var profileColor: Color = .accentColor + + var body: some View { + Form { + Section(header: Text("AniList"), footer: Text("Sora and cranci1 are not affiliated with AniList in any way.")) { + HStack() { + KFImage(URL(string: "https://raw.githubusercontent.com/cranci1/Ryu/2f10226aa087154974a70c1ec78aa83a47daced9/Ryu/Assets.xcassets/Listing/Anilist.imageset/anilist.png")) + .placeholder { + RoundedRectangle(cornerRadius: 10) + .fill(Color.gray.opacity(0.3)) + .frame(width: 80, height: 80) + .shimmering() + } + .resizable() + .frame(width: 80, height: 80) + .clipShape(Rectangle()) + .cornerRadius(10) + Text("AniList.co") + .font(.title2) + } + if isLoading { + ProgressView() + } else { + if isLoggedIn { + HStack(spacing: 0) { + Text("Logged in as ") + Text(username) + .foregroundColor(profileColor) + .font(.body) + .fontWeight(.semibold) + } + } else { + Text(status) + .multilineTextAlignment(.center) + } + } + Button(isLoggedIn ? "Log Out from AniList.co" : "Log In with AniList.co") { + if isLoggedIn { + logout() + } else { + login() + } + } + .font(.body) + } + } + .navigationTitle("Trackers") + .onAppear { + updateStatus() + } + } + + func login() { + status = "Starting authentication..." + AniListLogin.authenticate() + } + + func logout() { + removeTokenFromKeychain() + status = "You are not logged in" + isLoggedIn = false + username = "" + profileColor = .primary + } + + func updateStatus() { + if let token = getTokenFromKeychain() { + isLoggedIn = true + fetchUserInfo(token: token) + } else { + isLoggedIn = false + status = "You are not logged in" + } + } + + func fetchUserInfo(token: String) { + isLoading = true + let userInfoURL = URL(string: "https://graphql.anilist.co")! + var request = URLRequest(url: userInfoURL) + request.httpMethod = "POST" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let query = """ + { + Viewer { + id + name + options { + profileColor + } + } + } + """ + let body: [String: Any] = ["query": query] + + do { + request.httpBody = try JSONSerialization.data(withJSONObject: body, options: []) + } catch { + status = "Failed to serialize request" + Logger.shared.log("Failed to serialize request", type: "Error") + isLoading = false + return + } + + URLSession.shared.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + isLoading = false + if let error = error { + status = "Error: \(error.localizedDescription)" + Logger.shared.log("Error: \(error.localizedDescription)", type: "Error") + return + } + guard let data = data else { + status = "No data received" + Logger.shared.log("No data received", type: "Error") + return + } + do { + if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let dataDict = json["data"] as? [String: Any], + let viewer = dataDict["Viewer"] as? [String: Any], + let name = viewer["name"] as? String, + let options = viewer["options"] as? [String: Any], + let colorName = options["profileColor"] as? String { + + username = name + profileColor = colorFromName(colorName) + status = "Logged in as \(name)" + } else { + status = "Unexpected response format!" + Logger.shared.log("Unexpected response format!", type: "Error") + } + } catch { + status = "Failed to parse response: \(error.localizedDescription)" + Logger.shared.log("Failed to parse response: \(error.localizedDescription)", type: "Error") + } + } + }.resume() + } + + func colorFromName(_ name: String) -> Color { + switch name.lowercased() { + case "blue": + return .blue + case "purple": + return .purple + case "green": + return .green + case "orange": + return .orange + case "red": + return .red + case "pink": + return .pink + case "gray": + return .gray + default: + return .accentColor + } + } + + func getTokenFromKeychain() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "me.cranci.sora.AniListToken", + kSecAttrAccount as String: "AniListAccessToken", + kSecReturnData as String: kCFBooleanTrue!, + kSecMatchLimit as String: kSecMatchLimitOne + ] + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + guard status == errSecSuccess, let tokenData = item as? Data else { + return nil + } + return String(data: tokenData, encoding: .utf8) + } + + func removeTokenFromKeychain() { + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: "me.cranci.sora.AniListToken", + kSecAttrAccount as String: "AniListAccessToken" + ] + SecItemDelete(deleteQuery as CFDictionary) + } +} diff --git a/Sora/Views/SettingsView/SettingsView.swift b/Sora/Views/SettingsView/SettingsView.swift index ee43405..eacb628 100644 --- a/Sora/Views/SettingsView/SettingsView.swift +++ b/Sora/Views/SettingsView/SettingsView.swift @@ -11,9 +11,9 @@ struct SettingsView: View { var body: some View { NavigationView { Form { - Section(header: Text("Main Settings")) { + Section(header: Text("Main")) { NavigationLink(destination: SettingsViewGeneral()) { - Text("General Settings") + Text("General Preferences") } NavigationLink(destination: SettingsViewPlayer()) { Text("Media Player") @@ -21,6 +21,9 @@ struct SettingsView: View { NavigationLink(destination: SettingsViewModule()) { Text("Modules") } + //NavigationLink(destination: SettingsViewTrackers()) { + // Text("Trackers") + //} } Section(header: Text("Info")) { diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 738eb75..ad75420 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -9,14 +9,15 @@ /* Begin PBXBuildFile section */ 130217CC2D81C55E0011EFF5 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130217CB2D81C55E0011EFF5 /* DownloadView.swift */; }; 130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */; }; - 13103E842D589D8B000F0673 /* AniList-Seasonal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E832D589D8B000F0673 /* AniList-Seasonal.swift */; }; - 13103E862D58A328000F0673 /* AniList-Trending.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E852D58A328000F0673 /* AniList-Trending.swift */; }; 13103E892D58A39A000F0673 /* AniListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E882D58A39A000F0673 /* AniListItem.swift */; }; 13103E8B2D58E028000F0673 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8A2D58E028000F0673 /* View.swift */; }; 13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8D2D58E04A000F0673 /* SkeletonCell.swift */; }; 131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */ = {isa = PBXBuildFile; fileRef = 131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */; }; 1327FBA72D758CEA00FC6689 /* Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1327FBA62D758CEA00FC6689 /* Analytics.swift */; }; 1327FBA92D758DEA00FC6689 /* UIDevice+Model.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */; }; + 132E351D2D959DDB0007800E /* Drops in Frameworks */ = {isa = PBXBuildFile; productRef = 132E351C2D959DDB0007800E /* Drops */; }; + 132E35202D959E1D0007800E /* FFmpeg-iOS-Lame in Frameworks */ = {isa = PBXBuildFile; productRef = 132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */; }; + 132E35232D959E410007800E /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 132E35222D959E410007800E /* Kingfisher */; }; 1334FF4D2D786C93007E289F /* TMDB-Seasonal.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1334FF4C2D786C93007E289F /* TMDB-Seasonal.swift */; }; 1334FF4F2D786C9E007E289F /* TMDB-Trending.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1334FF4E2D786C9E007E289F /* TMDB-Trending.swift */; }; 1334FF522D7871B7007E289F /* TMDBItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1334FF512D7871B7007E289F /* TMDBItem.swift */; }; @@ -33,13 +34,9 @@ 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 */; }; 133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133F55BA2D33B55100E08EEA /* LibraryManager.swift */; }; 1359ED142D76F49900C13034 /* finTopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1359ED132D76F49900C13034 /* finTopView.swift */; }; - 1359ED1A2D76FA7D00C13034 /* Drops in Frameworks */ = {isa = PBXBuildFile; productRef = 1359ED192D76FA7D00C13034 /* Drops */; }; 135CCBE22D4D1138008B9C0E /* SettingsViewPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */; }; - 136F21B92D5B8DD8006409AC /* AniList-MediaInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136F21B82D5B8DD8006409AC /* AniList-MediaInfo.swift */; }; - 136F21BC2D5B8F29006409AC /* AniList-DetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136F21BB2D5B8F29006409AC /* AniList-DetailsView.swift */; }; 138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */; }; 138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */; }; 139935662D468C450065CEFF /* ModuleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139935652D468C450065CEFF /* ModuleManager.swift */; }; @@ -52,8 +49,11 @@ 13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13CBEFD92D5F7D1200D011EE /* String.swift */; }; 13D842552D45267500EBBFA6 /* DropManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13D842542D45267500EBBFA6 /* DropManager.swift */; }; 13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13D99CF62D4E73C300250A86 /* ModuleAdditionSettingsView.swift */; }; + 13DB468D2D90093A008CBC03 /* Anilist-Login.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB468B2D900939008CBC03 /* Anilist-Login.swift */; }; + 13DB468E2D90093A008CBC03 /* Anilist-Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB468C2D90093A008CBC03 /* Anilist-Token.swift */; }; + 13DB46902D900A38008CBC03 /* URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB468F2D900A38008CBC03 /* URL.swift */; }; + 13DB46922D900BCE008CBC03 /* SettingsViewTrackers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB46912D900BCE008CBC03 /* SettingsViewTrackers.swift */; }; 13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB7CC22D7D99C0004371D3 /* SubtitleSettingsManager.swift */; }; - 13DB7CC62D7DC7D2004371D3 /* FFmpeg-iOS-Lame in Frameworks */ = {isa = PBXBuildFile; productRef = 13DB7CC52D7DC7D2004371D3 /* FFmpeg-iOS-Lame */; }; 13DB7CEC2D7DED5D004371D3 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB7CEB2D7DED5D004371D3 /* DownloadManager.swift */; }; 13DC0C462D302C7500D0F966 /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DC0C452D302C7500D0F966 /* VideoPlayer.swift */; }; 13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */; }; @@ -61,14 +61,13 @@ 13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */; }; 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; }; 1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */; }; + 73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 130217CB2D81C55E0011EFF5 /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = ""; }; 130C6BF82D53A4C200DC1432 /* Sora.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Sora.entitlements; sourceTree = ""; }; 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewData.swift; sourceTree = ""; }; - 13103E832D589D8B000F0673 /* AniList-Seasonal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AniList-Seasonal.swift"; sourceTree = ""; }; - 13103E852D58A328000F0673 /* AniList-Trending.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AniList-Trending.swift"; sourceTree = ""; }; 13103E882D58A39A000F0673 /* AniListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AniListItem.swift; sourceTree = ""; }; 13103E8A2D58E028000F0673 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = ""; }; 13103E8D2D58E04A000F0673 /* SkeletonCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonCell.swift; sourceTree = ""; }; @@ -95,8 +94,6 @@ 133F55BA2D33B55100E08EEA /* LibraryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryManager.swift; sourceTree = ""; }; 1359ED132D76F49900C13034 /* finTopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = finTopView.swift; sourceTree = ""; }; 135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewPlayer.swift; sourceTree = ""; }; - 136F21B82D5B8DD8006409AC /* AniList-MediaInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AniList-MediaInfo.swift"; sourceTree = ""; }; - 136F21BB2D5B8F29006409AC /* AniList-DetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AniList-DetailsView.swift"; sourceTree = ""; }; 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCell.swift; sourceTree = ""; }; 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressBar.swift; sourceTree = ""; }; 139935652D468C450065CEFF /* ModuleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleManager.swift; sourceTree = ""; }; @@ -109,6 +106,10 @@ 13CBEFD92D5F7D1200D011EE /* String.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = String.swift; sourceTree = ""; }; 13D842542D45267500EBBFA6 /* DropManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropManager.swift; sourceTree = ""; }; 13D99CF62D4E73C300250A86 /* ModuleAdditionSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleAdditionSettingsView.swift; sourceTree = ""; }; + 13DB468B2D900939008CBC03 /* Anilist-Login.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Anilist-Login.swift"; sourceTree = ""; }; + 13DB468C2D90093A008CBC03 /* Anilist-Token.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Anilist-Token.swift"; sourceTree = ""; }; + 13DB468F2D900A38008CBC03 /* URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URL.swift; sourceTree = ""; }; + 13DB46912D900BCE008CBC03 /* SettingsViewTrackers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewTrackers.swift; sourceTree = ""; }; 13DB7CC22D7D99C0004371D3 /* SubtitleSettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtitleSettingsManager.swift; sourceTree = ""; }; 13DB7CEB2D7DED5D004371D3 /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = ""; }; 13DC0C412D2EC9BA00D0F966 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; @@ -118,6 +119,7 @@ 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NormalPlayer.swift; sourceTree = ""; }; 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = ""; }; 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = ""; }; + 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -125,9 +127,9 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 13DB7CC62D7DC7D2004371D3 /* FFmpeg-iOS-Lame in Frameworks */, - 1359ED1A2D76FA7D00C13034 /* Drops in Frameworks */, - 133D7C972D2BE2AF0075467E /* Kingfisher in Frameworks */, + 132E35232D959E410007800E /* Kingfisher in Frameworks */, + 132E35202D959E1D0007800E /* FFmpeg-iOS-Lame in Frameworks */, + 132E351D2D959DDB0007800E /* Drops in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -146,23 +148,12 @@ 13103E812D589D77000F0673 /* AniList */ = { isa = PBXGroup; children = ( - 136F21B72D5B8DAC006409AC /* MediaInfo */, + 13DB468A2D900919008CBC03 /* Auth */, 13103E872D58A392000F0673 /* Struct */, - 13103E822D589D7D000F0673 /* HomePage */, ); path = AniList; sourceTree = ""; }; - 13103E822D589D7D000F0673 /* HomePage */ = { - isa = PBXGroup; - children = ( - 136F21BA2D5B8F17006409AC /* DetailsView */, - 13103E832D589D8B000F0673 /* AniList-Seasonal.swift */, - 13103E852D58A328000F0673 /* AniList-Trending.swift */, - ); - path = HomePage; - sourceTree = ""; - }; 13103E872D58A392000F0673 /* Struct */ = { isa = PBXGroup; children = ( @@ -285,6 +276,7 @@ 131845F82D47C62D00CA7A54 /* SettingsViewGeneral.swift */, 135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */, 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */, + 13DB46912D900BCE008CBC03 /* SettingsViewTrackers.swift */, ); path = SettingsSubViews; sourceTree = ""; @@ -309,11 +301,13 @@ 133D7C862D2BE2640075467E /* Extensions */ = { isa = PBXGroup; children = ( + 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */, 1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */, 133D7C872D2BE2640075467E /* URLSession.swift */, 1359ED132D76F49900C13034 /* finTopView.swift */, 13CBEFD92D5F7D1200D011EE /* String.swift */, 13103E8A2D58E028000F0673 /* View.swift */, + 13DB468F2D900A38008CBC03 /* URL.swift */, ); path = Extensions; sourceTree = ""; @@ -345,22 +339,6 @@ path = LibraryView; sourceTree = ""; }; - 136F21B72D5B8DAC006409AC /* MediaInfo */ = { - isa = PBXGroup; - children = ( - 136F21B82D5B8DD8006409AC /* AniList-MediaInfo.swift */, - ); - path = MediaInfo; - sourceTree = ""; - }; - 136F21BA2D5B8F17006409AC /* DetailsView */ = { - isa = PBXGroup; - children = ( - 136F21BB2D5B8F29006409AC /* AniList-DetailsView.swift */, - ); - path = DetailsView; - sourceTree = ""; - }; 1384DCDF2D89BE870094797A /* Helpers */ = { isa = PBXGroup; children = ( @@ -413,6 +391,15 @@ path = Drops; sourceTree = ""; }; + 13DB468A2D900919008CBC03 /* Auth */ = { + isa = PBXGroup; + children = ( + 13DB468B2D900939008CBC03 /* Anilist-Login.swift */, + 13DB468C2D90093A008CBC03 /* Anilist-Token.swift */, + ); + path = Auth; + sourceTree = ""; + }; 13DB7CEA2D7DED50004371D3 /* DownloadManager */ = { isa = PBXGroup; children = ( @@ -467,9 +454,9 @@ ); name = Sulfur; packageProductDependencies = ( - 133D7C962D2BE2AF0075467E /* Kingfisher */, - 1359ED192D76FA7D00C13034 /* Drops */, - 13DB7CC52D7DC7D2004371D3 /* FFmpeg-iOS-Lame */, + 132E351C2D959DDB0007800E /* Drops */, + 132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */, + 132E35222D959E410007800E /* Kingfisher */, ); productName = Sora; productReference = 133D7C6A2D2BE2500075467E /* Sulfur.app */; @@ -499,9 +486,9 @@ ); mainGroup = 133D7C612D2BE2500075467E; packageReferences = ( - 133D7C952D2BE2AF0075467E /* XCRemoteSwiftPackageReference "Kingfisher" */, - 1359ED182D76FA7D00C13034 /* XCRemoteSwiftPackageReference "Drops" */, - 13DB7CC42D7DC7D2004371D3 /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */, + 132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */, + 132E351E2D959E1D0007800E /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */, + 132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */, ); productRefGroup = 133D7C6B2D2BE2500075467E /* Products */; projectDirPath = ""; @@ -539,12 +526,12 @@ 133D7C902D2BE2640075467E /* SettingsView.swift in Sources */, 1334FF4F2D786C9E007E289F /* TMDB-Trending.swift in Sources */, 13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */, + 13DB46902D900A38008CBC03 /* URL.swift in Sources */, 130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */, 1334FF542D787217007E289F /* TMDBRequest.swift in Sources */, 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */, 13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */, 133D7C932D2BE2640075467E /* Modules.swift in Sources */, - 136F21B92D5B8DD8006409AC /* AniList-MediaInfo.swift in Sources */, 13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */, 133D7C702D2BE2500075467E /* ContentView.swift in Sources */, 13DB7CEC2D7DED5D004371D3 /* DownloadManager.swift in Sources */, @@ -552,7 +539,6 @@ 13D99CF72D4E73C300250A86 /* ModuleAdditionSettingsView.swift in Sources */, 13C0E5EC2D5F85F800E7F619 /* ContinueWatchingItem.swift in Sources */, 13CBA0882D60F19C00EFE70A /* VTTSubtitlesLoader.swift in Sources */, - 13103E862D58A328000F0673 /* AniList-Trending.swift in Sources */, 13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */, 133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */, 13103E892D58A39A000F0673 /* AniListItem.swift in Sources */, @@ -563,20 +549,22 @@ 1327FBA92D758DEA00FC6689 /* UIDevice+Model.swift in Sources */, 138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */, 133D7C8C2D2BE2640075467E /* SearchView.swift in Sources */, + 13DB468E2D90093A008CBC03 /* Anilist-Token.swift in Sources */, 1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */, 133D7C942D2BE2640075467E /* JSController.swift in Sources */, 133D7C922D2BE2640075467E /* URLSession.swift in Sources */, 133D7C912D2BE2640075467E /* SettingsViewModule.swift in Sources */, - 136F21BC2D5B8F29006409AC /* AniList-DetailsView.swift in Sources */, 130217CC2D81C55E0011EFF5 /* DownloadView.swift in Sources */, 133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */, - 13103E842D589D8B000F0673 /* AniList-Seasonal.swift in Sources */, 133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */, 133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */, 138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */, 1334FF4D2D786C93007E289F /* TMDB-Seasonal.swift in Sources */, + 13DB468D2D90093A008CBC03 /* Anilist-Login.swift in Sources */, + 73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */, 13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */, 1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */, + 13DB46922D900BCE008CBC03 /* SettingsViewTrackers.swift in Sources */, 13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -810,15 +798,7 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 133D7C952D2BE2AF0075467E /* XCRemoteSwiftPackageReference "Kingfisher" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/onevcat/Kingfisher.git"; - requirement = { - kind = exactVersion; - version = 7.9.1; - }; - }; - 1359ED182D76FA7D00C13034 /* XCRemoteSwiftPackageReference "Drops" */ = { + 132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/omaralbeik/Drops.git"; requirement = { @@ -826,7 +806,7 @@ kind = branch; }; }; - 13DB7CC42D7DC7D2004371D3 /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */ = { + 132E351E2D959E1D0007800E /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/kewlbear/FFmpeg-iOS-Lame"; requirement = { @@ -834,24 +814,32 @@ kind = branch; }; }; + 132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/onevcat/Kingfisher.git"; + requirement = { + kind = exactVersion; + version = 7.9.1; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 133D7C962D2BE2AF0075467E /* Kingfisher */ = { + 132E351C2D959DDB0007800E /* Drops */ = { isa = XCSwiftPackageProductDependency; - package = 133D7C952D2BE2AF0075467E /* XCRemoteSwiftPackageReference "Kingfisher" */; - productName = Kingfisher; - }; - 1359ED192D76FA7D00C13034 /* Drops */ = { - isa = XCSwiftPackageProductDependency; - package = 1359ED182D76FA7D00C13034 /* XCRemoteSwiftPackageReference "Drops" */; + package = 132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */; productName = Drops; }; - 13DB7CC52D7DC7D2004371D3 /* FFmpeg-iOS-Lame */ = { + 132E351F2D959E1D0007800E /* FFmpeg-iOS-Lame */ = { isa = XCSwiftPackageProductDependency; - package = 13DB7CC42D7DC7D2004371D3 /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */; + package = 132E351E2D959E1D0007800E /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */; productName = "FFmpeg-iOS-Lame"; }; + 132E35222D959E410007800E /* Kingfisher */ = { + isa = XCSwiftPackageProductDependency; + package = 132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */; + productName = Kingfisher; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 133D7C622D2BE2500075467E /* Project object */;