diff --git a/.swiftlint.yml b/.swiftlint.yml index 063f69e..9a8a006 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,19 +1,15 @@ # Directory and file filters included: - - Plugins - Source - - Tests - - Package.swift -excluded: - - Tests/BuiltInRulesTests/Resources - - Tests/FrameworkTests/Resources # Enabled/disabled rules analyzer_rules: - unused_declaration - unused_import + opt_in_rules: - all + disabled_rules: - anonymous_argument_in_multiline_closure - async_without_await @@ -38,7 +34,6 @@ disabled_rules: - no_grouping_extension - no_magic_numbers - one_declaration_per_file - - prefer_key_path # Re-enable once we are on Swift 6. - prefer_nimble - prefixed_toplevel_constant - required_deinit @@ -49,6 +44,14 @@ disabled_rules: - trailing_closure - type_contents_order - vertical_whitespace_between_cases + # newly added: + - multiple_closures_with_trailing_closure + - closure_body_length + - file_name + - line_length + - nesting + - legacy_objc_type + - function_body_length # Configurations attributes: @@ -56,30 +59,11 @@ attributes: - "@ConfigurationElement" - "@OptionGroup" - "@RuleConfigurationDescriptionBuilder" -balanced_xctest_lifecycle: &unit_test_configuration - test_parent_classes: - - SwiftLintTestCase - - XCTestCase -closure_body_length: - warning: 50 - error: 100 -empty_xctest_method: *unit_test_configuration -file_name: - excluded: - - Exports.swift - - GeneratedTests.swift - - Macros.swift - - Reporters+Register.swift - - Rules+Register.swift - - Rules+Template.swift - - RuleConfigurationMacros.swift - - SwiftSyntax+SwiftLint.swift - - TestHelpers.swift -final_test_case: *unit_test_configuration -function_body_length: 60 + identifier_name: excluded: - id + large_tuple: 3 number_separator: minimum_length: 5 @@ -98,12 +82,6 @@ unused_import: # Custom rules custom_rules: - rule_id: - included: Source/SwiftLintBuiltInRules/Rules/.+/\w+\.swift - name: Rule ID - message: Rule IDs must be all lowercase, snake case and not end with `rule` - regex: ^\s+identifier:\s*("\w+_rule"|"\S*[^a-z_]\S*") - severity: error fatal_error: name: Fatal Error excluded: "Tests/*" @@ -112,9 +90,3 @@ custom_rules: match_kinds: - identifier severity: error - rule_test_function: - included: Tests/SwiftLintFrameworkTests/RulesTests.swift - name: Rule Test Function - message: Rule Test Function mustn't end with `rule` - regex: func\s*test\w+(r|R)ule\(\) - severity: error diff --git a/Localizable.xcstrings b/Localizable.xcstrings index d950c03..d2fb6d3 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -11,6 +11,16 @@ } } }, + "-10s" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "-10s" + } + } + } + }, "%@ - Episode %lld" : { "localizations" : { "de" : { @@ -79,6 +89,16 @@ } } }, + "+10s" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "+10s" + } + } + } + }, "25" : { "localizations" : { "de" : { @@ -349,6 +369,16 @@ } } }, + "Check out some community modules here!" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Schau dir einige Community-Module hier an!" + } + } + } + }, "Clear Cache" : { "localizations" : { "de" : { @@ -869,6 +899,16 @@ } } }, + "Loading %@…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wird geladen %@…" + } + } + } + }, "Loading Animation" : { "localizations" : { "de" : { @@ -1119,6 +1159,16 @@ } } }, + "No Data Available" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine Informationen verfügbar" + } + } + } + }, "No data received" : { "localizations" : { "de" : { @@ -1164,7 +1214,7 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Keine Module ausgewählt" + "value" : "Kein Modul ausgewählt" } } } @@ -1209,6 +1259,16 @@ } } }, + "Open Community Library" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Community-Bibliothek öffnen" + } + } + } + }, "Open in AniList" : { "localizations" : { "de" : { @@ -1599,6 +1659,16 @@ } } }, + "Subtitle Delay: %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Untertitel-Verzögerung: %@" + } + } + } + }, "Subtitle Settings" : { "localizations" : { "de" : { diff --git a/Sora/SoraApp.swift b/Sora/SoraApp.swift index fdfe467..722b897 100644 --- a/Sora/SoraApp.swift +++ b/Sora/SoraApp.swift @@ -71,7 +71,7 @@ struct SoraApp: App { case "default_page": if let comps = URLComponents(url: url, resolvingAgainstBaseURL: true), let libraryURL = comps.queryItems?.first(where: { $0.name == "url" })?.value { - + UserDefaults.standard.set(libraryURL, forKey: "lastCommunityURL") UserDefaults.standard.set(true, forKey: "didReceiveDefaultPageLink") @@ -93,7 +93,7 @@ struct SoraApp: App { } } } - + case "module": guard url.scheme == "sora", url.host == "module", diff --git a/Sora/Tracking Services/AniList/Auth/Anilist-Login.swift b/Sora/Tracking Services/AniList/Auth/Anilist-Login.swift index de958b1..cb5160d 100644 --- a/Sora/Tracking Services/AniList/Auth/Anilist-Login.swift +++ b/Sora/Tracking Services/AniList/Auth/Anilist-Login.swift @@ -10,15 +10,15 @@ 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 { diff --git a/Sora/Tracking Services/AniList/Auth/Anilist-Token.swift b/Sora/Tracking Services/AniList/Auth/Anilist-Token.swift index 883e0e9..8019520 100644 --- a/Sora/Tracking Services/AniList/Auth/Anilist-Token.swift +++ b/Sora/Tracking Services/AniList/Auth/Anilist-Token.swift @@ -12,38 +12,38 @@ 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 let authSuccessNotification = Notification.Name("AniListAuthenticationSuccess") static let authFailureNotification = Notification.Name("AniListAuthenticationFailure") - + 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") DispatchQueue.main.async { @@ -52,15 +52,15 @@ class AniListToken { } 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 + + let task = URLSession.shared.dataTask(with: request) { data, _, error in DispatchQueue.main.async { if let error = error { Logger.shared.log("Error: \(error.localizedDescription)", type: "Error") @@ -68,14 +68,14 @@ class AniListToken { completion(false) return } - + guard let data = data else { Logger.shared.log("No data received", type: "Error") NotificationCenter.default.post(name: authFailureNotification, object: nil, userInfo: ["error": "No data received"]) completion(false) return } - + do { if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { if let accessToken = json["access_token"] as? String { @@ -100,7 +100,7 @@ class AniListToken { } } } - + task.resume() } } diff --git a/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift b/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift index 4383e1f..6ee21b6 100644 --- a/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift +++ b/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift @@ -10,11 +10,11 @@ import Security class AniListMutation { let apiURL = URL(string: "https://graphql.anilist.co")! - + func getTokenFromKeychain() -> String? { let serviceName = "me.cranci.sora.AniListToken" let accountName = "AniListAccessToken" - + let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, @@ -22,28 +22,28 @@ class AniListMutation { 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 updateAnimeProgress(animeId: Int, episodeNumber: Int, completion: @escaping (Result) -> Void) { if let sendPushUpdates = UserDefaults.standard.object(forKey: "sendPushUpdates") as? Bool, sendPushUpdates == false { return } - + guard let userToken = getTokenFromKeychain() else { completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Access token not found"]))) return } - + let query = """ mutation ($mediaId: Int, $progress: Int, $status: MediaListStatus) { SaveMediaListEntry (mediaId: $mediaId, progress: $progress, status: $status) { @@ -53,41 +53,41 @@ class AniListMutation { } } """ - + let variables: [String: Any] = [ "mediaId": animeId, "progress": episodeNumber, "status": "CURRENT" ] - + let requestBody: [String: Any] = [ "query": query, "variables": variables ] - + guard let jsonData = try? JSONSerialization.data(withJSONObject: requestBody, options: []) else { completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to serialize JSON"]))) return } - + var request = URLRequest(url: apiURL) request.httpMethod = "POST" request.setValue("Bearer \(userToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = jsonData - + let task = URLSession.shared.dataTask(with: request) { data, response, error in if let error = error { completion(.failure(error)) return } - + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 completion(.failure(NSError(domain: "", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Unexpected response or status code"]))) return } - + if let data = data { do { _ = try JSONSerialization.jsonObject(with: data, options: []) @@ -100,10 +100,10 @@ class AniListMutation { completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "No data received"]))) } } - + task.resume() } - + func fetchMalID(animeId: Int, completion: @escaping (Result) -> Void) { let query = """ query ($id: Int) { @@ -128,7 +128,7 @@ class AniListMutation { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = jsonData - URLSession.shared.dataTask(with: request) { data, resp, error in + URLSession.shared.dataTask(with: request) { data, _, error in if let e = error { return completion(.failure(e)) } @@ -141,7 +141,7 @@ class AniListMutation { completion(.success(mal)) }.resume() } - + private struct AniListMediaResponse: Decodable { struct DataField: Decodable { struct Media: Decodable { let idMal: Int? } diff --git a/Sora/Tracking Services/Trakt/Auth/Trakt-Login.swift b/Sora/Tracking Services/Trakt/Auth/Trakt-Login.swift index fc0c9c3..ab94923 100644 --- a/Sora/Tracking Services/Trakt/Auth/Trakt-Login.swift +++ b/Sora/Tracking Services/Trakt/Auth/Trakt-Login.swift @@ -10,15 +10,15 @@ import UIKit class TraktLogin { static let clientID = "6ec81bf19deb80fdfa25652eef101576ca6aaa0dc016d36079b2de413d71c369" static let redirectURI = "sora://trakt" - + static let authorizationEndpoint = "https://trakt.tv/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 { diff --git a/Sora/Tracking Services/Trakt/Auth/Trakt-Token.swift b/Sora/Tracking Services/Trakt/Auth/Trakt-Token.swift index 6e726cf..6ab08e2 100644 --- a/Sora/Tracking Services/Trakt/Auth/Trakt-Token.swift +++ b/Sora/Tracking Services/Trakt/Auth/Trakt-Token.swift @@ -12,46 +12,46 @@ class TraktToken { static let clientID = "6ec81bf19deb80fdfa25652eef101576ca6aaa0dc016d36079b2de413d71c369" static let clientSecret = "17cd92f71da3be9d755e2d8a6506fb3c3ecee19a247a6f0120ce2fb1f359850b" static let redirectURI = "sora://trakt" - + static let tokenEndpoint = "https://api.trakt.tv/oauth/token" static let serviceName = "me.cranci.sora.TraktToken" static let accessTokenKey = "TraktAccessToken" static let refreshTokenKey = "TraktRefreshToken" - + static let authSuccessNotification = Notification.Name("TraktAuthenticationSuccess") static let authFailureNotification = Notification.Name("TraktAuthenticationFailure") - + private static func saveToKeychain(key: String, data: String) -> Bool { let tokenData = data.data(using: .utf8)! - + let deleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, kSecAttrAccount as String: key ] SecItemDelete(deleteQuery as CFDictionary) - + let addQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: serviceName, kSecAttrAccount as String: key, kSecValueData as String: tokenData ] - + return SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess } - + static func exchangeAuthorizationCodeForToken(code: String, completion: @escaping (Bool) -> Void) { guard let url = URL(string: tokenEndpoint) else { Logger.shared.log("Invalid token endpoint URL", type: "Error") handleFailure(error: "Invalid token endpoint URL", completion: completion) return } - + var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") - + let bodyData: [String: Any] = [ "code": code, "client_id": clientID, @@ -59,25 +59,25 @@ class TraktToken { "redirect_uri": redirectURI, "grant_type": "authorization_code" ] - + processTokenRequest(request: request, bodyData: bodyData, completion: completion) } - + static func refreshAccessToken(completion: @escaping (Bool) -> Void) { guard let refreshToken = getRefreshToken() else { handleFailure(error: "No refresh token available", completion: completion) return } - + guard let url = URL(string: tokenEndpoint) else { handleFailure(error: "Invalid token endpoint URL", completion: completion) return } - + var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") - + let bodyData: [String: Any] = [ "refresh_token": refreshToken, "client_id": clientID, @@ -85,40 +85,40 @@ class TraktToken { "redirect_uri": redirectURI, "grant_type": "refresh_token" ] - + processTokenRequest(request: request, bodyData: bodyData, completion: completion) } - + private static func processTokenRequest(request: URLRequest, bodyData: [String: Any], completion: @escaping (Bool) -> Void) { var request = request - + do { request.httpBody = try JSONSerialization.data(withJSONObject: bodyData) } catch { handleFailure(error: "Failed to create request body", completion: completion) return } - - let task = URLSession.shared.dataTask(with: request) { data, response, error in + + let task = URLSession.shared.dataTask(with: request) { data, _, error in DispatchQueue.main.async { if let error = error { handleFailure(error: error.localizedDescription, completion: completion) return } - + guard let data = data else { handleFailure(error: "No data received", completion: completion) return } - + do { if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { if let accessToken = json["access_token"] as? String, let refreshToken = json["refresh_token"] as? String { - + let accessSuccess = saveToKeychain(key: accessTokenKey, data: accessToken) let refreshSuccess = saveToKeychain(key: refreshTokenKey, data: refreshToken) - + if accessSuccess && refreshSuccess { NotificationCenter.default.post(name: authSuccessNotification, object: nil) completion(true) @@ -135,16 +135,16 @@ class TraktToken { } } } - + task.resume() } - + private static func handleFailure(error: String, completion: @escaping (Bool) -> Void) { Logger.shared.log(error, type: "Error") NotificationCenter.default.post(name: authFailureNotification, object: nil, userInfo: ["error": error]) completion(false) } - + private static func getRefreshToken() -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -152,19 +152,19 @@ class TraktToken { kSecAttrAccount as String: refreshTokenKey, kSecReturnData as String: true ] - + var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) - + guard status == errSecSuccess, let tokenData = result as? Data, let token = String(data: tokenData, encoding: .utf8) else { return nil } - + return token } - + static func getAccessToken() -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -172,37 +172,37 @@ class TraktToken { kSecAttrAccount as String: accessTokenKey, kSecReturnData as String: true ] - + var result: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &result) - + guard status == errSecSuccess, let tokenData = result as? Data, let token = String(data: tokenData, encoding: .utf8) else { return nil } - + return token } - + static func validateToken(completion: @escaping (Bool) -> Void) { guard let token = getAccessToken() else { completion(false) return } - + guard let url = URL(string: "https://api.trakt.tv/users/settings") else { completion(false) return } - + var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("2", forHTTPHeaderField: "trakt-api-version") request.setValue(clientID, forHTTPHeaderField: "trakt-api-key") - + let task = URLSession.shared.dataTask(with: request) { _, response, _ in DispatchQueue.main.async { if let httpResponse = response as? HTTPURLResponse { @@ -213,10 +213,10 @@ class TraktToken { } } } - + task.resume() } - + static func validateAndRefreshTokenIfNeeded(completion: @escaping (Bool) -> Void) { if getAccessToken() == nil { if getRefreshToken() != nil { @@ -226,7 +226,7 @@ class TraktToken { } return } - + validateToken { isValid in if isValid { completion(true) @@ -239,7 +239,7 @@ class TraktToken { } } } - + static func checkAuthenticationStatus(completion: @escaping (Bool) -> Void) { validateAndRefreshTokenIfNeeded(completion: completion) } diff --git a/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift b/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift index 2108e9c..48eed3f 100644 --- a/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift +++ b/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift @@ -10,7 +10,7 @@ import Security class TraktMutation { let apiURL = URL(string: "https://api.trakt.tv")! - + func getTokenFromKeychain() -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -19,7 +19,7 @@ class TraktMutation { kSecReturnData as String: kCFBooleanTrue!, kSecMatchLimit as String: kSecMatchLimitOne ] - + var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) guard status == errSecSuccess, @@ -29,11 +29,11 @@ class TraktMutation { } return token } - + enum ExternalIDType { case imdb(String) case tmdb(Int) - + var dictionary: [String: Any] { switch self { case .imdb(let id): @@ -43,21 +43,21 @@ class TraktMutation { } } } - + func markAsWatched(type: String, externalID: ExternalIDType, episodeNumber: Int? = nil, seasonNumber: Int? = nil, completion: @escaping (Result) -> Void) { if let sendTraktUpdates = UserDefaults.standard.object(forKey: "sendTraktUpdates") as? Bool, sendTraktUpdates == false { return } - + guard let userToken = getTokenFromKeychain() else { completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Access token not found"]))) return } - + let endpoint = "/sync/history" let body: [String: Any] - + switch type { case "movie": body = [ @@ -67,13 +67,13 @@ class TraktMutation { ] ] ] - + case "episode": guard let episode = episodeNumber, let season = seasonNumber else { completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing episode or season number"]))) return } - + body = [ "shows": [ [ @@ -89,43 +89,43 @@ class TraktMutation { ] ] ] - + default: completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid content type"]))) return } - + var request = URLRequest(url: apiURL.appendingPathComponent(endpoint)) request.httpMethod = "POST" request.setValue("Bearer \(userToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("2", forHTTPHeaderField: "trakt-api-version") request.setValue(TraktToken.clientID, forHTTPHeaderField: "trakt-api-key") - + do { request.httpBody = try JSONSerialization.data(withJSONObject: body) } catch { completion(.failure(error)) return } - - let task = URLSession.shared.dataTask(with: request) { data, response, error in + + let task = URLSession.shared.dataTask(with: request) { _, response, error in if let error = error { completion(.failure(error)) return } - + guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 completion(.failure(NSError(domain: "", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Unexpected response or status code"]))) return } - + Logger.shared.log("Successfully updated watch status on Trakt", type: "Debug") completion(.success(())) } - + task.resume() } } diff --git a/Sora/Utils/Analytics/Analytics.swift b/Sora/Utils/Analytics/Analytics.swift index e8b62ad..6b5d4a2 100644 --- a/Sora/Utils/Analytics/Analytics.swift +++ b/Sora/Utils/Analytics/Analytics.swift @@ -18,36 +18,35 @@ struct AnalyticsResponse: Codable { // MARK: - Analytics Manager class AnalyticsManager { - + static let shared = AnalyticsManager() private let analyticsURL = URL(string: "http://151.106.3.14:47474/analytics")! private let moduleManager = ModuleManager() - + private init() {} - + // MARK: - Send Analytics Data func sendEvent(event: String, additionalData: [String: Any] = [:]) { - + let defaults = UserDefaults.standard - + // Ensure the key is set with a default value if missing if defaults.object(forKey: "analyticsEnabled") == nil { defaults.setValue(false, forKey: "analyticsEnabled") } - - + let analyticsEnabled = UserDefaults.standard.bool(forKey: "analyticsEnabled") - + guard analyticsEnabled else { Logger.shared.log("Analytics is disabled, skipping event: \(event)", type: "Debug") return } - + guard let selectedModule = getSelectedModule() else { Logger.shared.log("No selected module found", type: "Debug") return } - + // Prepare analytics data var safeAdditionalData = additionalData @@ -55,7 +54,7 @@ class AnalyticsManager { if let errorValue = additionalData["error"] as? NSError { safeAdditionalData["error"] = errorValue.localizedDescription } - + let analyticsData: [String: Any] = [ "event": event, "device": getDeviceModel(), @@ -64,34 +63,34 @@ class AnalyticsManager { "module_version": selectedModule.metadata.version, "data": safeAdditionalData ] - + sendRequest(with: analyticsData) } - + // MARK: - Private Request Method private func sendRequest(with data: [String: Any]) { var request = URLRequest(url: analyticsURL) request.httpMethod = "POST" request.addValue("application/json", forHTTPHeaderField: "Content-Type") - + do { request.httpBody = try JSONSerialization.data(withJSONObject: data, options: []) } catch { Logger.shared.log("Failed to encode JSON: \(error.localizedDescription)", type: "Debug") return } - + URLSession.shared.dataTask(with: request) { (data, _, error) in if let error = error { Logger.shared.log("Request failed: \(error.localizedDescription)", type: "Debug") return } - + guard let data = data else { Logger.shared.log("No data received from server", type: "Debug") return } - + do { let decodedResponse = try JSONDecoder().decode(AnalyticsResponse.self, from: data) if decodedResponse.status == "success" { @@ -104,18 +103,17 @@ class AnalyticsManager { } }.resume() } - + // MARK: - Get App Version private func getAppVersion() -> String { return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown_version" } - + // MARK: - Get Device Model private func getDeviceModel() -> String { return UIDevice.modelName } - - + // MARK: - Get Selected Module private func getSelectedModule() -> ScrapingModule? { guard let selectedModuleId = UserDefaults.standard.string(forKey: "selectedModuleId") else { return nil } diff --git a/Sora/Utils/ContinueWatching/ContinueWatchingManager.swift b/Sora/Utils/ContinueWatching/ContinueWatchingManager.swift index 4b66a3d..9d143d9 100644 --- a/Sora/Utils/ContinueWatching/ContinueWatchingManager.swift +++ b/Sora/Utils/ContinueWatching/ContinueWatchingManager.swift @@ -24,7 +24,7 @@ class ContinueWatchingManager: ObservableObject { @objc private func handleiCloudSync() { NotificationCenter.default.post(name: .ContinueWatchingDidUpdate, object: nil) } - + func save(item: ContinueWatchingItem) { if item.progress >= 0.9 { remove(item: item) @@ -43,7 +43,7 @@ class ContinueWatchingManager: ObservableObject { userDefaultsSuite.set(data, forKey: storageKey) } } - + func loadItems() { if let data = userDefaultsSuite.data(forKey: storageKey), let parsedItems = try? JSONDecoder().decode([ContinueWatchingItem].self, from: data) { @@ -52,7 +52,7 @@ class ContinueWatchingManager: ObservableObject { items = [] } } - + func remove(item: ContinueWatchingItem) { items.removeAll { $0.id == item.id } if let data = try? JSONEncoder().encode(items) { diff --git a/Sora/Utils/Drops/DropManager.swift b/Sora/Utils/Drops/DropManager.swift index 8a56eb3..11b38c8 100644 --- a/Sora/Utils/Drops/DropManager.swift +++ b/Sora/Utils/Drops/DropManager.swift @@ -10,12 +10,12 @@ import UIKit class DropManager { static let shared = DropManager() - + private init() {} - + func showDrop(title: String, subtitle: String, duration: TimeInterval, icon: UIImage?) { let position: Drop.Position = .top - + let drop = Drop( title: title, subtitle: subtitle, diff --git a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift index 18f4d39..a1c7446 100644 --- a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift +++ b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift @@ -10,25 +10,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 { @@ -63,7 +63,7 @@ extension JSContext { task.resume() } self.setObject(fetchNativeFunction, forKeyedSubscript: "fetchNative" as NSString) - + let fetchDefinition = """ function fetch(url, headers) { return new Promise(function(resolve, reject) { @@ -73,45 +73,44 @@ extension JSContext { """ self.evaluateScript(fetchDefinition) } - + func setupFetchV2() { - let fetchV2NativeFunction: @convention(block) (String, [String: String]?, String?, String?, ObjCBool,JSValue, JSValue) -> Void = { urlString, headers, method, body, redirect, resolve, reject in + let fetchV2NativeFunction: @convention(block) (String, [String: String]?, String?, String?, ObjCBool, JSValue, JSValue) -> Void = { urlString, headers, method, body, redirect, 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) } } - Logger.shared.log("Redirect value is \(redirect.boolValue)",type:"Error") + Logger.shared.log("Redirect value is \(redirect.boolValue)", type: "Error") let task = URLSession.fetchData(allowRedirects: redirect.boolValue).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"]) @@ -123,18 +122,18 @@ extension JSContext { "headers": (response as? HTTPURLResponse)?.allHeaderFields ?? [:], "body": "" ] - + 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) { - + responseDict["body"] = text resolve.call(withArguments: [responseDict]) } else { @@ -142,7 +141,7 @@ extension JSContext { Logger.shared.log("Unable to decode data to text", type: "Error") resolve.call(withArguments: [responseDict]) } - + } catch { Logger.shared.log("Error reading downloaded file: \(error.localizedDescription)", type: "Error") reject.call(withArguments: ["Error reading downloaded file"]) @@ -150,21 +149,20 @@ extension JSContext { } task.resume() } - - + self.setObject(fetchV2NativeFunction, forKeyedSubscript: "fetchV2Native" as NSString) - + let fetchv2Definition = """ function fetchv2(url, headers = {}, method = "GET", body = null, redirect = true ) { - - + + var processedBody = null; if(method != "GET") { // Ensure body is properly serialized processedBody = body ? JSON.stringify(body) : null } - + return new Promise(function(resolve, reject) { fetchV2Native(url, headers, method, processedBody, redirect, function(rawText) { const responseObj = { @@ -186,11 +184,11 @@ extension JSContext { }, reject); }); } - + """ self.evaluateScript(fetchv2Definition) } - + func setupBase64Functions() { let btoaFunction: @convention(block) (String) -> String? = { data in guard let data = data.data(using: .utf8) else { @@ -199,20 +197,20 @@ extension JSContext { } 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() diff --git a/Sora/Utils/Extensions/String.swift b/Sora/Utils/Extensions/String.swift index 10b3b5a..0937e50 100644 --- a/Sora/Utils/Extensions/String.swift +++ b/Sora/Utils/Extensions/String.swift @@ -17,7 +17,7 @@ extension String { let attributedString = try? NSAttributedString(data: data, options: options, documentAttributes: nil) return attributedString?.string ?? self } - + var trimmed: String { return self.trimmingCharacters(in: .whitespacesAndNewlines) } diff --git a/Sora/Utils/Extensions/UIDevice+Model.swift b/Sora/Utils/Extensions/UIDevice+Model.swift index e07970d..05ae506 100644 --- a/Sora/Utils/Extensions/UIDevice+Model.swift +++ b/Sora/Utils/Extensions/UIDevice+Model.swift @@ -8,7 +8,7 @@ import UIKit public extension UIDevice { - + static let modelName: String = { var systemInfo = utsname() uname(&systemInfo) @@ -17,7 +17,7 @@ public extension UIDevice { guard let value = element.value as? Int8, value != 0 else { return identifier } return identifier + String(UnicodeScalar(UInt8(value))) } - + func mapToDevice(identifier: String) -> String { // swiftlint:disable:this cyclomatic_complexity #if os(iOS) switch identifier { @@ -220,7 +220,7 @@ public extension UIDevice { } #endif } - + return mapToDevice(identifier: identifier) }() } diff --git a/Sora/Utils/Extensions/URLSession.swift b/Sora/Utils/Extensions/URLSession.swift index b19ee75..58e9100 100644 --- a/Sora/Utils/Extensions/URLSession.swift +++ b/Sora/Utils/Extensions/URLSession.swift @@ -7,25 +7,21 @@ import Foundation // URL DELEGATE CLASS FOR FETCH API -class FetchDelegate: NSObject, URLSessionTaskDelegate -{ +class FetchDelegate: NSObject, URLSessionTaskDelegate { private let allowRedirects: Bool init(allowRedirects: Bool) { self.allowRedirects = allowRedirects } // This handles the redirection and prevents it. func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) { - if(allowRedirects) - { + if allowRedirects { completionHandler(request) // Allow Redirect - } - else - { + } else { completionHandler(nil) // Block Redirect } - + } - + } extension URLSession { static let userAgents = [ @@ -53,24 +49,22 @@ extension URLSession { "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 var randomUserAgent: String = { userAgents.randomElement() ?? userAgents[0] }() - + static let custom: URLSession = { let configuration = URLSessionConfiguration.default configuration.httpAdditionalHeaders = ["User-Agent": randomUserAgent] return URLSession(configuration: configuration) }() - + // return url session that redirects based on input - static func fetchData(allowRedirects:Bool) -> URLSession - { - let delegate = FetchDelegate(allowRedirects:allowRedirects) + static func fetchData(allowRedirects: Bool) -> URLSession { + let delegate = FetchDelegate(allowRedirects: allowRedirects) let configuration = URLSessionConfiguration.default configuration.httpAdditionalHeaders = ["User-Agent": randomUserAgent] return URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil) } } - diff --git a/Sora/Utils/Extensions/finTopView.swift b/Sora/Utils/Extensions/finTopView.swift index adcdfd7..9ee5c0c 100644 --- a/Sora/Utils/Extensions/finTopView.swift +++ b/Sora/Utils/Extensions/finTopView.swift @@ -12,16 +12,16 @@ class findTopViewController { if let presented = viewController.presentedViewController { return findViewController(presented) } - + if let navigationController = viewController as? UINavigationController { return findViewController(navigationController.visibleViewController ?? navigationController) } - + if let tabBarController = viewController as? UITabBarController, let selected = tabBarController.selectedViewController { return findViewController(selected) } - + return viewController } } diff --git a/Sora/Utils/JSLoader/JSController-Details.swift b/Sora/Utils/JSLoader/JSController-Details.swift index 8ba89b9..6240209 100644 --- a/Sora/Utils/JSLoader/JSController-Details.swift +++ b/Sora/Utils/JSLoader/JSController-Details.swift @@ -8,32 +8,32 @@ import JavaScriptCore extension JSController { - + func fetchDetails(url: String, completion: @escaping ([MediaItem], [EpisodeLink]) -> Void) { guard let url = URL(string: url) else { completion([], []) return } - + URLSession.custom.dataTask(with: url) { [weak self] data, _, error in guard let self = self else { return } - + if let error = error { - Logger.shared.log("Network error: \(error)",type: "Error") + Logger.shared.log("Network error: \(error)", type: "Error") DispatchQueue.main.async { completion([], []) } return } - + guard let data = data, let html = String(data: data, encoding: .utf8) else { - Logger.shared.log("Failed to decode HTML",type: "Error") + Logger.shared.log("Failed to decode HTML", type: "Error") DispatchQueue.main.async { completion([], []) } return } - + var resultItems: [MediaItem] = [] var episodeLinks: [EpisodeLink] = [] - - Logger.shared.log(html,type: "HTMLStrings") + + Logger.shared.log(html, type: "HTMLStrings") if let parseFunction = self.context.objectForKeyedSubscript("extractDetails"), let results = parseFunction.call(withArguments: [html]).toArray() as? [[String: String]] { resultItems = results.map { item in @@ -44,9 +44,9 @@ extension JSController { ) } } else { - Logger.shared.log("Failed to parse results",type: "Error") + Logger.shared.log("Failed to parse results", type: "Error") } - + if let fetchEpisodesFunction = self.context.objectForKeyedSubscript("extractEpisodes"), let episodesResult = fetchEpisodesFunction.call(withArguments: [html]).toArray() as? [[String: String]] { for episodeData in episodesResult { @@ -55,52 +55,52 @@ extension JSController { } } } - + DispatchQueue.main.async { completion(resultItems, episodeLinks) } }.resume() } - + func fetchDetailsJS(url: String, completion: @escaping ([MediaItem], [EpisodeLink]) -> Void) { guard let url = URL(string: url) else { completion([], []) return } - + if let exception = context.exception { - Logger.shared.log("JavaScript exception: \(exception)",type: "Error") + Logger.shared.log("JavaScript exception: \(exception)", type: "Error") completion([], []) return } - + guard let extractDetailsFunction = context.objectForKeyedSubscript("extractDetails") else { - Logger.shared.log("No JavaScript function extractDetails found",type: "Error") + Logger.shared.log("No JavaScript function extractDetails found", type: "Error") completion([], []) return } - + guard let extractEpisodesFunction = context.objectForKeyedSubscript("extractEpisodes") else { - Logger.shared.log("No JavaScript function extractEpisodes found",type: "Error") + Logger.shared.log("No JavaScript function extractEpisodes found", type: "Error") completion([], []) return } - + var resultItems: [MediaItem] = [] var episodeLinks: [EpisodeLink] = [] - + let dispatchGroup = DispatchGroup() - + dispatchGroup.enter() let promiseValueDetails = extractDetailsFunction.call(withArguments: [url.absoluteString]) guard let promiseDetails = promiseValueDetails else { - Logger.shared.log("extractDetails did not return a Promise",type: "Error") + Logger.shared.log("extractDetails did not return a Promise", type: "Error") completion([], []) return } - + let thenBlockDetails: @convention(block) (JSValue) -> Void = { result in - Logger.shared.log(result.toString(),type: "Debug") + Logger.shared.log(result.toString(), type: "Debug") if let jsonOfDetails = result.toString(), let dataDetails = jsonOfDetails.data(using: .utf8) { do { @@ -113,38 +113,38 @@ extension JSController { ) } } else { - Logger.shared.log("Failed to parse JSON of extractDetails",type: "Error") + Logger.shared.log("Failed to parse JSON of extractDetails", type: "Error") } } catch { - Logger.shared.log("JSON parsing error of extract details: \(error)",type: "Error") + Logger.shared.log("JSON parsing error of extract details: \(error)", type: "Error") } } else { - Logger.shared.log("Result is not a string of extractDetails",type: "Error") + Logger.shared.log("Result is not a string of extractDetails", type: "Error") } dispatchGroup.leave() } - + let catchBlockDetails: @convention(block) (JSValue) -> Void = { error in - Logger.shared.log("Promise rejected of extractDetails: \(String(describing: error.toString()))",type: "Error") + Logger.shared.log("Promise rejected of extractDetails: \(String(describing: error.toString()))", type: "Error") dispatchGroup.leave() } - + let thenFunctionDetails = JSValue(object: thenBlockDetails, in: context) let catchFunctionDetails = JSValue(object: catchBlockDetails, in: context) - + promiseDetails.invokeMethod("then", withArguments: [thenFunctionDetails as Any]) promiseDetails.invokeMethod("catch", withArguments: [catchFunctionDetails as Any]) - + dispatchGroup.enter() let promiseValueEpisodes = extractEpisodesFunction.call(withArguments: [url.absoluteString]) guard let promiseEpisodes = promiseValueEpisodes else { - Logger.shared.log("extractEpisodes did not return a Promise",type: "Error") + Logger.shared.log("extractEpisodes did not return a Promise", type: "Error") completion([], []) return } - + let thenBlockEpisodes: @convention(block) (JSValue) -> Void = { result in - Logger.shared.log(result.toString(),type: "Debug") + Logger.shared.log(result.toString(), type: "Debug") if let jsonOfEpisodes = result.toString(), let dataEpisodes = jsonOfEpisodes.data(using: .utf8) { do { @@ -156,28 +156,28 @@ extension JSController { ) } } else { - Logger.shared.log("Failed to parse JSON of extractEpisodes",type: "Error") + Logger.shared.log("Failed to parse JSON of extractEpisodes", type: "Error") } } catch { - Logger.shared.log("JSON parsing error of extractEpisodes: \(error)",type: "Error") + Logger.shared.log("JSON parsing error of extractEpisodes: \(error)", type: "Error") } } else { - Logger.shared.log("Result is not a string of extractEpisodes",type: "Error") + Logger.shared.log("Result is not a string of extractEpisodes", type: "Error") } dispatchGroup.leave() } - + let catchBlockEpisodes: @convention(block) (JSValue) -> Void = { error in - Logger.shared.log("Promise rejected of extractEpisodes: \(String(describing: error.toString()))",type: "Error") + Logger.shared.log("Promise rejected of extractEpisodes: \(String(describing: error.toString()))", type: "Error") dispatchGroup.leave() } - + let thenFunctionEpisodes = JSValue(object: thenBlockEpisodes, in: context) let catchFunctionEpisodes = JSValue(object: catchBlockEpisodes, in: context) - + promiseEpisodes.invokeMethod("then", withArguments: [thenFunctionEpisodes as Any]) promiseEpisodes.invokeMethod("catch", withArguments: [catchFunctionEpisodes as Any]) - + dispatchGroup.notify(queue: .main) { completion(resultItems, episodeLinks) } diff --git a/Sora/Utils/JSLoader/JSController-Search.swift b/Sora/Utils/JSLoader/JSController-Search.swift index 007019e..2538c73 100644 --- a/Sora/Utils/JSLoader/JSController-Search.swift +++ b/Sora/Utils/JSLoader/JSController-Search.swift @@ -8,31 +8,31 @@ import JavaScriptCore extension JSController { - + func fetchSearchResults(keyword: String, module: ScrapingModule, completion: @escaping ([SearchItem]) -> Void) { let searchUrl = module.metadata.searchBaseUrl.replacingOccurrences(of: "%s", with: keyword.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "") - + guard let url = URL(string: searchUrl) else { completion([]) return } - + URLSession.custom.dataTask(with: url) { [weak self] data, _, error in guard let self = self else { return } - + if let error = error { - Logger.shared.log("Network error: \(error)",type: "Error") + Logger.shared.log("Network error: \(error)", type: "Error") DispatchQueue.main.async { completion([]) } return } - + guard let data = data, let html = String(data: data, encoding: .utf8) else { - Logger.shared.log("Failed to decode HTML",type: "Error") + Logger.shared.log("Failed to decode HTML", type: "Error") DispatchQueue.main.async { completion([]) } return } - - Logger.shared.log(html,type: "HTMLStrings") + + Logger.shared.log(html, type: "HTMLStrings") if let parseFunction = self.context.objectForKeyedSubscript("searchResults"), let results = parseFunction.call(withArguments: [html]).toArray() as? [[String: String]] { let resultItems = results.map { item in @@ -46,35 +46,35 @@ extension JSController { completion(resultItems) } } else { - Logger.shared.log("Failed to parse results",type: "Error") + Logger.shared.log("Failed to parse results", type: "Error") DispatchQueue.main.async { completion([]) } } }.resume() } - + func fetchJsSearchResults(keyword: String, module: ScrapingModule, completion: @escaping ([SearchItem]) -> Void) { if let exception = context.exception { - Logger.shared.log("JavaScript exception: \(exception)",type: "Error") + Logger.shared.log("JavaScript exception: \(exception)", type: "Error") completion([]) return } - + guard let searchResultsFunction = context.objectForKeyedSubscript("searchResults") else { - Logger.shared.log("No JavaScript function searchResults found",type: "Error") + Logger.shared.log("No JavaScript function searchResults found", type: "Error") completion([]) return } - + let promiseValue = searchResultsFunction.call(withArguments: [keyword]) guard let promise = promiseValue else { - Logger.shared.log("searchResults did not return a Promise",type: "Error") + Logger.shared.log("searchResults did not return a Promise", type: "Error") completion([]) return } - + let thenBlock: @convention(block) (JSValue) -> Void = { result in - - Logger.shared.log(result.toString(),type: "HTMLStrings") + + Logger.shared.log(result.toString(), type: "HTMLStrings") if let jsonString = result.toString(), let data = jsonString.data(using: .utf8) { do { @@ -88,41 +88,41 @@ extension JSController { } return SearchItem(title: title, imageUrl: imageUrl, href: href) } - + DispatchQueue.main.async { completion(resultItems) } - + } else { - Logger.shared.log("Failed to parse JSON",type: "Error") + Logger.shared.log("Failed to parse JSON", type: "Error") DispatchQueue.main.async { completion([]) } } } catch { - Logger.shared.log("JSON parsing error: \(error)",type: "Error") + Logger.shared.log("JSON parsing error: \(error)", type: "Error") DispatchQueue.main.async { completion([]) } } } else { - Logger.shared.log("Result is not a string",type: "Error") + Logger.shared.log("Result is not a string", type: "Error") DispatchQueue.main.async { completion([]) } } } - + let catchBlock: @convention(block) (JSValue) -> Void = { error in - Logger.shared.log("Promise rejected: \(String(describing: error.toString()))",type: "Error") + Logger.shared.log("Promise rejected: \(String(describing: error.toString()))", type: "Error") DispatchQueue.main.async { completion([]) } } - + let thenFunction = JSValue(object: thenBlock, in: context) let catchFunction = JSValue(object: catchBlock, in: context) - + promise.invokeMethod("then", withArguments: [thenFunction as Any]) promise.invokeMethod("catch", withArguments: [catchFunction as Any]) } diff --git a/Sora/Utils/JSLoader/JSController-Streams.swift b/Sora/Utils/JSLoader/JSController-Streams.swift index 85615de..837b561 100644 --- a/Sora/Utils/JSLoader/JSController-Streams.swift +++ b/Sora/Utils/JSLoader/JSController-Streams.swift @@ -8,37 +8,37 @@ import JavaScriptCore extension JSController { - + func fetchStreamUrl(episodeUrl: String, softsub: Bool = false, module: ScrapingModule, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) { guard let url = URL(string: episodeUrl) else { completion((nil, nil)) return } - + URLSession.custom.dataTask(with: url) { [weak self] data, _, error in guard let self = self else { return } - + if let error = error { Logger.shared.log("Network error: \(error)", type: "Error") DispatchQueue.main.async { completion((nil, nil)) } return } - + guard let data = data, let html = String(data: data, encoding: .utf8) else { Logger.shared.log("Failed to decode HTML", type: "Error") DispatchQueue.main.async { completion((nil, nil)) } return } - + Logger.shared.log(html, type: "HTMLStrings") if let parseFunction = self.context.objectForKeyedSubscript("extractStreamUrl"), let resultString = parseFunction.call(withArguments: [html]).toString() { if let data = resultString.data(using: .utf8) { do { if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { - var streamUrls: [String]? = nil - var subtitleUrls: [String]? = nil - + var streamUrls: [String]? + var subtitleUrls: [String]? + if let streamsArray = json["streams"] as? [String] { streamUrls = streamsArray Logger.shared.log("Found \(streamsArray.count) streams", type: "Stream") @@ -46,7 +46,7 @@ extension JSController { streamUrls = [streamUrl] Logger.shared.log("Found single stream", type: "Stream") } - + if let subsArray = json["subtitles"] as? [String] { subtitleUrls = subsArray Logger.shared.log("Found \(subsArray.count) subtitle tracks", type: "Stream") @@ -54,14 +54,14 @@ extension JSController { subtitleUrls = [subtitleUrl] Logger.shared.log("Found single subtitle track", type: "Stream") } - + Logger.shared.log("Starting stream with \(streamUrls?.count ?? 0) sources and \(subtitleUrls?.count ?? 0) subtitles", type: "Stream") DispatchQueue.main.async { completion((streamUrls, subtitleUrls)) } return } - + if let streamsArray = try? JSONSerialization.jsonObject(with: data, options: []) as? [String] { Logger.shared.log("Starting multi-stream with \(streamsArray.count) sources", type: "Stream") DispatchQueue.main.async { completion((streamsArray, nil)) } @@ -69,7 +69,7 @@ extension JSController { } } } - + Logger.shared.log("Starting stream from: \(resultString)", type: "Stream") DispatchQueue.main.async { completion(([resultString], nil)) } } else { @@ -78,37 +78,37 @@ extension JSController { } }.resume() } - + func fetchStreamUrlJS(episodeUrl: String, softsub: Bool = false, module: ScrapingModule, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) { if let exception = context.exception { Logger.shared.log("JavaScript exception: \(exception)", type: "Error") completion((nil, nil)) return } - + guard let extractStreamUrlFunction = context.objectForKeyedSubscript("extractStreamUrl") else { Logger.shared.log("No JavaScript function extractStreamUrl found", type: "Error") completion((nil, nil)) return } - + let promiseValue = extractStreamUrlFunction.call(withArguments: [episodeUrl]) guard let promise = promiseValue else { Logger.shared.log("extractStreamUrl did not return a Promise", type: "Error") completion((nil, nil)) return } - + let thenBlock: @convention(block) (JSValue) -> Void = { [weak self] result in guard self != nil else { return } - + if let jsonString = result.toString(), let data = jsonString.data(using: .utf8) { do { if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { - var streamUrls: [String]? = nil - var subtitleUrls: [String]? = nil - + var streamUrls: [String]? + var subtitleUrls: [String]? + if let streamsArray = json["streams"] as? [String] { streamUrls = streamsArray Logger.shared.log("Found \(streamsArray.count) streams", type: "Stream") @@ -116,7 +116,7 @@ extension JSController { streamUrls = [streamUrl] Logger.shared.log("Found single stream", type: "Stream") } - + if let subsArray = json["subtitles"] as? [String] { subtitleUrls = subsArray Logger.shared.log("Found \(subsArray.count) subtitle tracks", type: "Stream") @@ -124,14 +124,14 @@ extension JSController { subtitleUrls = [subtitleUrl] Logger.shared.log("Found single subtitle track", type: "Stream") } - + Logger.shared.log("Starting stream with \(streamUrls?.count ?? 0) sources and \(subtitleUrls?.count ?? 0) subtitles", type: "Stream") DispatchQueue.main.async { completion((streamUrls, subtitleUrls)) } return } - + if let streamsArray = try? JSONSerialization.jsonObject(with: data, options: []) as? [String] { Logger.shared.log("Starting multi-stream with \(streamsArray.count) sources", type: "Stream") DispatchQueue.main.async { completion((streamsArray, nil)) } @@ -139,75 +139,75 @@ extension JSController { } } } - + let streamUrl = result.toString() Logger.shared.log("Starting stream from: \(streamUrl ?? "nil")", type: "Stream") DispatchQueue.main.async { completion((streamUrl != nil ? [streamUrl!] : nil, nil)) } } - + let catchBlock: @convention(block) (JSValue) -> Void = { error in Logger.shared.log("Promise rejected: \(String(describing: error.toString()))", type: "Error") DispatchQueue.main.async { completion((nil, nil)) } } - + let thenFunction = JSValue(object: thenBlock, in: context) let catchFunction = JSValue(object: catchBlock, in: context) - + promise.invokeMethod("then", withArguments: [thenFunction as Any]) promise.invokeMethod("catch", withArguments: [catchFunction as Any]) } - + func fetchStreamUrlJSSecond(episodeUrl: String, softsub: Bool = false, module: ScrapingModule, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) { let url = URL(string: episodeUrl)! - let task = URLSession.custom.dataTask(with: url) { [weak self] data, response, error in + let task = URLSession.custom.dataTask(with: url) { [weak self] data, _, error in guard let self = self else { return } - + if let error = error { Logger.shared.log("URLSession error: \(error.localizedDescription)", type: "Error") DispatchQueue.main.async { completion((nil, nil)) } return } - + guard let data = data, let htmlString = String(data: data, encoding: .utf8) else { Logger.shared.log("Failed to fetch HTML data", type: "Error") DispatchQueue.main.async { completion((nil, nil)) } return } - + DispatchQueue.main.async { if let exception = self.context.exception { Logger.shared.log("JavaScript exception: \(exception)", type: "Error") completion((nil, nil)) return } - + guard let extractStreamUrlFunction = self.context.objectForKeyedSubscript("extractStreamUrl") else { Logger.shared.log("No JavaScript function extractStreamUrl found", type: "Error") completion((nil, nil)) return } - + let promiseValue = extractStreamUrlFunction.call(withArguments: [htmlString]) guard let promise = promiseValue else { Logger.shared.log("extractStreamUrl did not return a Promise", type: "Error") completion((nil, nil)) return } - + let thenBlock: @convention(block) (JSValue) -> Void = { [weak self] result in guard self != nil else { return } - + if let jsonString = result.toString(), let data = jsonString.data(using: .utf8) { do { if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] { - var streamUrls: [String]? = nil - var subtitleUrls: [String]? = nil - + var streamUrls: [String]? + var subtitleUrls: [String]? + if let streamsArray = json["streams"] as? [String] { streamUrls = streamsArray Logger.shared.log("Found \(streamsArray.count) streams", type: "Stream") @@ -215,7 +215,7 @@ extension JSController { streamUrls = [streamUrl] Logger.shared.log("Found single stream", type: "Stream") } - + if let subsArray = json["subtitles"] as? [String] { subtitleUrls = subsArray Logger.shared.log("Found \(subsArray.count) subtitle tracks", type: "Stream") @@ -223,14 +223,14 @@ extension JSController { subtitleUrls = [subtitleUrl] Logger.shared.log("Found single subtitle track", type: "Stream") } - + Logger.shared.log("Starting stream with \(streamUrls?.count ?? 0) sources and \(subtitleUrls?.count ?? 0) subtitles", type: "Stream") DispatchQueue.main.async { completion((streamUrls, subtitleUrls)) } return } - + if let streamsArray = try? JSONSerialization.jsonObject(with: data, options: []) as? [String] { Logger.shared.log("Starting multi-stream with \(streamsArray.count) sources", type: "Stream") DispatchQueue.main.async { completion((streamsArray, nil)) } @@ -238,24 +238,24 @@ extension JSController { } } } - + let streamUrl = result.toString() Logger.shared.log("Starting stream from: \(streamUrl ?? "nil")", type: "Stream") DispatchQueue.main.async { completion((streamUrl != nil ? [streamUrl!] : nil, nil)) } } - + let catchBlock: @convention(block) (JSValue) -> Void = { error in Logger.shared.log("Promise rejected: \(String(describing: error.toString()))", type: "Error") DispatchQueue.main.async { completion((nil, nil)) } } - + let thenFunction = JSValue(object: thenBlock, in: self.context) let catchFunction = JSValue(object: catchBlock, in: self.context) - + promise.invokeMethod("then", withArguments: [thenFunction as Any]) promise.invokeMethod("catch", withArguments: [catchFunction as Any]) } diff --git a/Sora/Utils/JSLoader/JSController.swift b/Sora/Utils/JSLoader/JSController.swift index 2485ded..68994bf 100644 --- a/Sora/Utils/JSLoader/JSController.swift +++ b/Sora/Utils/JSLoader/JSController.swift @@ -9,16 +9,16 @@ import JavaScriptCore class JSController: ObservableObject { var context: JSContext - + init() { self.context = JSContext() setupContext() } - + func setupContext() { context.setupJavaScriptEnvironment() } - + func loadScript(_ script: String) { context = JSContext() setupContext() diff --git a/Sora/Utils/Logger/Logger.swift b/Sora/Utils/Logger/Logger.swift index 70ad048..369bc03 100644 --- a/Sora/Utils/Logger/Logger.swift +++ b/Sora/Utils/Logger/Logger.swift @@ -9,51 +9,50 @@ import Foundation class Logger { static let shared = Logger() - + struct LogEntry { let message: String let type: String let timestamp: Date } - + private var logs: [LogEntry] = [] private let logFileURL: URL private let logFilterViewModel = LogFilterViewModel.shared - + private init() { let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first! logFileURL = documentDirectory.appendingPathComponent("logs.txt") } - + func log(_ message: String, type: String = "General") { guard logFilterViewModel.isFilterEnabled(for: type) else { return } - + let entry = LogEntry(message: message, type: type, timestamp: Date()) logs.append(entry) saveLogToFile(entry) - + debugLog(entry) } - - + func getLogs() -> String { 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") } - + func clearLogs() { logs.removeAll() try? FileManager.default.removeItem(at: logFileURL) } - + private func saveLogToFile(_ log: LogEntry) { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "dd-MM HH:mm:ss" - + let logString = "[\(dateFormatter.string(from: log.timestamp))] [\(log.type)] \(log.message)\n---\n" - + if let data = logString.data(using: .utf8) { if FileManager.default.fileExists(atPath: logFileURL.path) { if let handle = try? FileHandle(forWritingTo: logFileURL) { @@ -66,7 +65,7 @@ class Logger { } } } - + /// Prints log messages to the Xcode console only in DEBUG mode private func debugLog(_ entry: LogEntry) { #if DEBUG diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Components/Double+Extension.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Components/Double+Extension.swift index 90e0550..d05841e 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/Components/Double+Extension.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/Components/Double+Extension.swift @@ -26,7 +26,7 @@ extension BinaryFloatingPoint { 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 { @@ -62,12 +62,12 @@ struct AniSkipResponse: Decodable { struct Result: Decodable { struct Interval: Decodable { let startTime: Double - let endTime: Double + let endTime: Double } let interval: Interval let skipType: String } - let found: Bool - let results: [Result] + let found: Bool + let results: [Result] let statusCode: Int } diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift index 5c97e1d..3cee987 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift @@ -22,11 +22,11 @@ struct MusicProgressSlider: View { let outroSegments: [ClosedRange] let introColor: Color let outroColor: Color - + @State private var localRealProgress: T = 0 @State private var localTempProgress: T = 0 @GestureState private var isActive: Bool = false - + var body: some View { GeometryReader { bounds in ZStack { @@ -44,7 +44,7 @@ struct MusicProgressSlider: View { Spacer() } } - + // Outro Segments ForEach(outroSegments, id: \.self) { segment in HStack(spacing: 0) { @@ -56,7 +56,7 @@ struct MusicProgressSlider: View { Spacer() } } - + Capsule() .fill(emptyColor) } @@ -78,7 +78,7 @@ struct MusicProgressSlider: View { } }) } - + HStack { let shouldShowHours = inRange.upperBound >= 3600 Text(value.asTimeString(style: .positional, showHours: shouldShowHours)) @@ -138,7 +138,7 @@ struct MusicProgressSlider: View { let percentage = correctedStartValue / range return percentage } - + private func getPrgValue() -> T { return ((localRealProgress + localTempProgress) * (inRange.upperBound - inRange.lowerBound)) + inRange.lowerBound } diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Components/VolumeSlider.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Components/VolumeSlider.swift index 15437c6..7ad4c61 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/Components/VolumeSlider.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/Components/VolumeSlider.swift @@ -42,7 +42,7 @@ struct VolumeSlider: View { } } } - + Image(systemName: getIconName) .font(.system(size: 20, weight: .bold, design: .rounded)) .frame(width: 30) @@ -100,7 +100,7 @@ struct VolumeSlider: View { let lowThreshold: T = 0.2 let midThreshold: T = 0.35 let highThreshold: T = 0.7 - + switch p { case muteThreshold: return "speaker.slash.fill" @@ -117,7 +117,7 @@ struct VolumeSlider: View { private func handleIconTap() { let currentProgress = localRealProgress + localTempProgress - + withAnimation { if currentProgress <= 0 { value = lastVolumeValue diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index c8e6b3c..b5ce7e0 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -24,50 +24,49 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele let subtitlesURL: String? let onWatchNext: () -> Void let aniListID: Int - + private var aniListUpdatedSuccessfully = false private var aniListUpdateImpossible: Bool = false private var aniListRetryCount = 0 private let aniListMaxRetries = 6 - + var player: AVPlayer! var timeObserverToken: Any? var inactivityTimer: Timer? var updateTimer: Timer? var originalRate: Float = 1.0 var holdGesture: UILongPressGestureRecognizer? - + var isPlaying = true var currentTimeVal: Double = 0.0 var duration: Double = 0.0 var isVideoLoaded = false - + private var isHoldPauseEnabled: Bool { UserDefaults.standard.bool(forKey: "holdForPauseEnabled") } - + private var isSkip85Visible: Bool { if UserDefaults.standard.object(forKey: "skip85Visible") == nil { return true } return UserDefaults.standard.bool(forKey: "skip85Visible") } - + private var isDoubleTapSkipEnabled: Bool { if UserDefaults.standard.object(forKey: "doubleTapSeekEnabled") == nil { return false } return UserDefaults.standard.bool(forKey: "doubleTapSeekEnabled") } - + var portraitButtonVisibleConstraints: [NSLayoutConstraint] = [] var portraitButtonHiddenConstraints: [NSLayoutConstraint] = [] var landscapeButtonVisibleConstraints: [NSLayoutConstraint] = [] var landscapeButtonHiddenConstraints: [NSLayoutConstraint] = [] var currentMarqueeConstraints: [NSLayoutConstraint] = [] private var currentMenuButtonTrailing: NSLayoutConstraint! - - + var subtitleForegroundColor: String = "white" var subtitleBackgroundEnabled: Bool = true var subtitleFontSize: Double = 20.0 @@ -80,7 +79,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele subtitleStackView.isHidden = !subtitlesEnabled } } - + var marqueeLabel: MarqueeLabel! var playerViewController: AVPlayerViewController! var controlsContainerView: UIView! @@ -97,20 +96,20 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele var skip85Button: UIButton! var qualityButton: UIButton! var holdSpeedIndicator: UIButton! - + var isHLSStream: Bool = false var qualities: [(String, String)] = [] var currentQualityURL: URL? var baseM3U8URL: URL? - + var sliderHostingController: UIHostingController>? var sliderViewModel = SliderViewModel() var isSliderEditing = false - + var watchNextButtonNormalConstraints: [NSLayoutConstraint] = [] var watchNextButtonControlsConstraints: [NSLayoutConstraint] = [] var isControlsVisible = false - + private var subtitleBottomToSliderConstraint: NSLayoutConstraint? private var subtitleBottomToSafeAreaConstraint: NSLayoutConstraint? var subtitleBottomPadding: CGFloat = 10.0 { @@ -118,12 +117,12 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele updateSubtitleLabelConstraints() } } - + private var wasPlayingBeforeSeek = false - + private var malID: Int? private var skipIntervals: (op: CMTimeRange?, ed: CMTimeRange?) = (nil, nil) - + private var skipIntroButton: UIButton! private var skipOutroButton: UIButton! private let skipButtonBaseAlpha: CGFloat = 0.9 @@ -134,17 +133,17 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private var originalOutroLeading: CGFloat = 0 private var skipIntroDismissedInSession = false private var skipOutroDismissedInSession = false - + private var playerItemKVOContext = 0 private var loadedTimeRangesObservation: NSKeyValueObservation? private var playerTimeControlStatusObserver: NSKeyValueObservation? - + private var isDimmed = false private var dimButton: UIButton! private var dimButtonToSlider: NSLayoutConstraint! private var dimButtonToRight: NSLayoutConstraint! private var dimButtonTimer: Timer? - + private lazy var controlsToHide: [UIView] = [ dismissButton, playPauseButton, @@ -159,9 +158,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele watchNextButton, volumeSliderHostingView! ] - + private var originalHiddenStates: [UIView: Bool] = [:] - + private var volumeObserver: NSKeyValueObservation? private var audioSession = AVAudioSession.sharedInstance() private var hiddenVolumeView = MPVolumeView(frame: .zero) @@ -170,7 +169,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private var volumeViewModel = VolumeViewModel() var volumeSliderHostingView: UIView? private var subtitleDelay: Double = 0.0 - + init(module: ScrapingModule, continueWatchingManager: ContinueWatchingManager, urlString: String, @@ -181,7 +180,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele subtitlesURL: String?, aniListID: Int, episodeImageUrl: String) { - + self.module = module self.continueWatchingManager = continueWatchingManager self.streamURL = urlString @@ -192,38 +191,38 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self.onWatchNext = onWatchNext self.subtitlesURL = subtitlesURL self.aniListID = aniListID - + super.init(nibName: nil, bundle: nil) - + guard let url = URL(string: urlString) else { fatalError("Invalid URL string") } - + var request = URLRequest(url: url) request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer") request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin") request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent") - + let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]]) let playerItem = AVPlayerItem(asset: asset) self.player = AVPlayer(playerItem: playerItem) - + let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(fullUrl)") if lastPlayedTime > 0 { let seekTime = CMTime(seconds: lastPlayedTime, preferredTimescale: 1) self.player.seek(to: seekTime) } } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black - + setupHoldGesture() loadSubtitleSettings() setupPlayerViewController() @@ -246,9 +245,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele setupAudioSession() updateSkipButtonsVisibility() setupHoldSpeedIndicator() - + view.bringSubviewToFront(subtitleStackView) - + AniListMutation().fetchMalID(animeId: aniListID) { [weak self] result in switch result { case .success(let mal): @@ -256,44 +255,44 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self?.fetchSkipTimes(type: "op") self?.fetchSkipTimes(type: "ed") case .failure(let error): - Logger.shared.log("Unable to fetch MAL ID: \(error)",type:"Error") + Logger.shared.log("Unable to fetch MAL ID: \(error)", type: "Error") } } - + controlsToHide.forEach { originalHiddenStates[$0] = $0.isHidden } - + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.checkForHLSStream() } - + if isHoldPauseEnabled { holdForPause() } - + do { try audioSession.setActive(true) } catch { Logger.shared.log("Error activating audio session: \(error)", type: "Debug") } - + volumeViewModel.value = Double(audioSession.outputVolume) - - volumeObserver = audioSession.observe(\.outputVolume, options: [.new]) { [weak self] session, change in + + volumeObserver = audioSession.observe(\.outputVolume, options: [.new]) { [weak self] _, change in guard let newVol = change.newValue else { return } DispatchQueue.main.async { self?.volumeViewModel.value = Double(newVol) Logger.shared.log("Hardware volume changed, new value: \(newVol)", type: "Debug") } } - + if #available(iOS 16.0, *) { playerViewController.allowsVideoFrameAnalysis = false } - + if let url = subtitlesURL, !url.isEmpty { subtitlesLoader.load(from: url) } - + DispatchQueue.main.async { self.isControlsVisible = true NSLayoutConstraint.deactivate(self.watchNextButtonNormalConstraints) @@ -308,35 +307,35 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele hiddenVolumeView.isHidden = true view.addSubview(hiddenVolumeView) - + hiddenVolumeView.translatesAutoresizingMaskIntoConstraints = false hiddenVolumeView.widthAnchor.constraint(equalToConstant: 1).isActive = true hiddenVolumeView.heightAnchor.constraint(equalToConstant: 1).isActive = true hiddenVolumeView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true hiddenVolumeView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true - + if let slider = hiddenVolumeView.subviews.first(where: { $0 is UISlider }) as? UISlider { systemVolumeSlider = slider } } - + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { super.viewWillTransition(to: size, with: coordinator) coordinator.animate(alongsideTransition: { _ in self.updateMarqueeConstraints() }) } - + override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - + guard let marqueeLabel = marqueeLabel else { return } - + let availableWidth = marqueeLabel.frame.width let textWidth = marqueeLabel.intrinsicContentSize.width - + if textWidth > availableWidth { marqueeLabel.lineBreakMode = .byTruncatingTail } else { @@ -344,63 +343,62 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } updateMenuButtonConstraints() } - + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) player?.play() setInitialPlayerRate() } - + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) NotificationCenter.default.addObserver(self, selector: #selector(playerItemDidChange), name: .AVPlayerItemNewAccessLogEntry, object: nil) skip85Button?.isHidden = !isSkip85Visible } - + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) if let playbackSpeed = player?.rate { UserDefaults.standard.set(playbackSpeed, forKey: "lastPlaybackSpeed") } - + if let token = timeObserverToken { player.removeTimeObserver(token) timeObserverToken = nil } - + loadedTimeRangesObservation?.invalidate() loadedTimeRangesObservation = nil - + updateTimer?.invalidate() inactivityTimer?.invalidate() - + player.pause() } - - override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { + + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { guard context == &playerItemKVOContext else { super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context) return } } - - + @objc private func playerItemDidChange() { DispatchQueue.main.async { [weak self] in guard let self = self else { return } if self.qualityButton.isHidden && self.isHLSStream { self.qualityButton.isHidden = false self.qualityButton.menu = self.qualitySelectionMenu() - + self.updateMenuButtonConstraints() - + UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) { self.view.layoutIfNeeded() } } } } - + private func getSegmentsColor() -> Color { if let data = UserDefaults.standard.data(forKey: "segmentsColorData"), let uiColor = try? NSKeyedUnarchiver.unarchivedObject(ofClass: UIColor.self, from: data) { @@ -408,7 +406,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } return .yellow } - + func setupPlayerViewController() { playerViewController = AVPlayerViewController() playerViewController.player = player @@ -423,11 +421,11 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele playerViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) playerViewController.didMove(toParent: self) - + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(toggleControls)) view.addGestureRecognizer(tapGesture) } - + func setupControls() { controlsContainerView = UIView() controlsContainerView.backgroundColor = UIColor.black.withAlphaComponent(0.0) @@ -439,7 +437,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele controlsContainerView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), controlsContainerView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor) ]) - + blackCoverView = UIView() blackCoverView.backgroundColor = UIColor.black.withAlphaComponent(0.4) blackCoverView.translatesAutoresizingMaskIntoConstraints = false @@ -450,77 +448,76 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele blackCoverView.leadingAnchor.constraint(equalTo: view.leadingAnchor), blackCoverView.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) - + backwardButton = UIImageView(image: UIImage(systemName: "gobackward")) backwardButton.tintColor = .white backwardButton.contentMode = .scaleAspectFit backwardButton.isUserInteractionEnabled = true - + backwardButton.layer.shadowColor = UIColor.black.cgColor backwardButton.layer.shadowOffset = CGSize(width: 0, height: 2) backwardButton.layer.shadowOpacity = 0.6 backwardButton.layer.shadowRadius = 4 backwardButton.layer.masksToBounds = false - + let backwardTap = UITapGestureRecognizer(target: self, action: #selector(seekBackward)) backwardTap.numberOfTapsRequired = 1 backwardButton.addGestureRecognizer(backwardTap) - + let backwardLongPress = UILongPressGestureRecognizer(target: self, action: #selector(seekBackwardLongPress(_:))) backwardLongPress.minimumPressDuration = 0.5 backwardButton.addGestureRecognizer(backwardLongPress) backwardTap.require(toFail: backwardLongPress) - + controlsContainerView.addSubview(backwardButton) backwardButton.translatesAutoresizingMaskIntoConstraints = false - + playPauseButton = UIImageView(image: UIImage(systemName: "pause.fill")) playPauseButton.tintColor = .white playPauseButton.contentMode = .scaleAspectFit playPauseButton.isUserInteractionEnabled = true - + playPauseButton.layer.shadowColor = UIColor.black.cgColor playPauseButton.layer.shadowOffset = CGSize(width: 0, height: 2) playPauseButton.layer.shadowOpacity = 0.6 playPauseButton.layer.shadowRadius = 4 playPauseButton.layer.masksToBounds = false - + let playPauseTap = UITapGestureRecognizer(target: self, action: #selector(togglePlayPause)) playPauseTap.delaysTouchesBegan = false playPauseTap.delegate = self playPauseButton.addGestureRecognizer(playPauseTap) - - + playPauseButton.addGestureRecognizer(playPauseTap) controlsContainerView.addSubview(playPauseButton) playPauseButton.translatesAutoresizingMaskIntoConstraints = false - + forwardButton = UIImageView(image: UIImage(systemName: "goforward")) forwardButton.tintColor = .white forwardButton.contentMode = .scaleAspectFit forwardButton.isUserInteractionEnabled = true - + forwardButton.layer.shadowColor = UIColor.black.cgColor forwardButton.layer.shadowOffset = CGSize(width: 0, height: 2) forwardButton.layer.shadowOpacity = 0.6 forwardButton.layer.shadowRadius = 4 forwardButton.layer.masksToBounds = false - + let forwardTap = UITapGestureRecognizer(target: self, action: #selector(seekForward)) forwardTap.numberOfTapsRequired = 1 forwardButton.addGestureRecognizer(forwardTap) - + let forwardLongPress = UILongPressGestureRecognizer(target: self, action: #selector(seekForwardLongPress(_:))) forwardLongPress.minimumPressDuration = 0.5 forwardButton.addGestureRecognizer(forwardLongPress) - + forwardTap.require(toFail: forwardLongPress) - + controlsContainerView.addSubview(forwardButton) forwardButton.translatesAutoresizingMaskIntoConstraints = false - + let segmentsColor = self.getSegmentsColor() - + let sliderView = MusicProgressSlider( value: Binding( get: { self.sliderViewModel.sliderValue }, @@ -535,10 +532,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele onEditingChanged: { editing in if editing { self.isSliderEditing = true - + self.wasPlayingBeforeSeek = (self.player.timeControlStatus == .playing) self.originalRate = self.player.rate - + self.player.pause() } else { let target = CMTime(seconds: self.sliderViewModel.sliderValue, @@ -549,12 +546,12 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele toleranceAfter: .zero ) { [weak self] _ in guard let self = self else { return } - + let final = self.player.currentTime().seconds self.sliderViewModel.sliderValue = final self.currentTimeVal = final self.isSliderEditing = false - + if self.wasPlayingBeforeSeek { self.player.playImmediately(atRate: self.originalRate) } @@ -566,45 +563,45 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele introColor: segmentsColor, outroColor: segmentsColor ) - + sliderHostingController = UIHostingController(rootView: sliderView) guard let sliderHostView = sliderHostingController?.view else { return } sliderHostView.backgroundColor = .clear sliderHostView.translatesAutoresizingMaskIntoConstraints = false controlsContainerView.addSubview(sliderHostView) - + NSLayoutConstraint.activate([ sliderHostView.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 18), sliderHostView.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -18), sliderHostView.bottomAnchor.constraint(equalTo: controlsContainerView.bottomAnchor, constant: -20), sliderHostView.heightAnchor.constraint(equalToConstant: 30) ]) - + NSLayoutConstraint.activate([ playPauseButton.centerXAnchor.constraint(equalTo: controlsContainerView.centerXAnchor), playPauseButton.centerYAnchor.constraint(equalTo: controlsContainerView.centerYAnchor), playPauseButton.widthAnchor.constraint(equalToConstant: 50), playPauseButton.heightAnchor.constraint(equalToConstant: 50), - + backwardButton.centerYAnchor.constraint(equalTo: playPauseButton.centerYAnchor), backwardButton.trailingAnchor.constraint(equalTo: playPauseButton.leadingAnchor, constant: -50), backwardButton.widthAnchor.constraint(equalToConstant: 40), backwardButton.heightAnchor.constraint(equalToConstant: 40), - + forwardButton.centerYAnchor.constraint(equalTo: playPauseButton.centerYAnchor), forwardButton.leadingAnchor.constraint(equalTo: playPauseButton.trailingAnchor, constant: 50), forwardButton.widthAnchor.constraint(equalToConstant: 40), forwardButton.heightAnchor.constraint(equalToConstant: 40) ]) } - + func holdForPause() { let holdForPauseGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleHoldForPause(_:))) holdForPauseGesture.minimumPressDuration = 1 holdForPauseGesture.numberOfTouchesRequired = 2 view.addGestureRecognizer(holdForPauseGesture) } - + func addInvisibleControlOverlays() { let playPauseOverlay = UIButton(type: .custom) playPauseOverlay.backgroundColor = .clear @@ -618,13 +615,13 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele playPauseOverlay.heightAnchor.constraint(equalTo: playPauseButton.heightAnchor, constant: 20) ]) } - + func setupSkipAndDismissGestures() { if isDoubleTapSkipEnabled { 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 { @@ -633,7 +630,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } } } - + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(_:))) if let introSwipe = skipIntroButton.gestureRecognizers?.first( where: { $0 is UISwipeGestureRecognizer && ($0 as! UISwipeGestureRecognizer).direction == .left } @@ -644,18 +641,18 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele panGesture.require(toFail: introSwipe) panGesture.require(toFail: outroSwipe) } - + view.addGestureRecognizer(panGesture) } - + 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 @@ -663,16 +660,16 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele 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), @@ -688,9 +685,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele imageView.heightAnchor.constraint(equalToConstant: 100) ]) } - + view.addSubview(circleView) - + if direction == "forward" { NSLayoutConstraint.activate([ circleView.centerYAnchor.constraint(equalTo: view.centerYAnchor), @@ -706,7 +703,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele circleView.heightAnchor.constraint(equalToConstant: diameter) ]) } - + UIView.animate(withDuration: 0.2, animations: { circleView.backgroundColor = UIColor.white.withAlphaComponent(0.5) imageView.alpha = 0.8 @@ -720,37 +717,37 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele }) } } - + func setupSubtitleLabel() { subtitleStackView = UIStackView() subtitleStackView.axis = .vertical subtitleStackView.alignment = .center subtitleStackView.distribution = .fill subtitleStackView.spacing = 2 - + if let subtitleStackView = subtitleStackView { view.addSubview(subtitleStackView) subtitleStackView.translatesAutoresizingMaskIntoConstraints = false - + subtitleBottomToSliderConstraint = subtitleStackView.bottomAnchor.constraint( equalTo: sliderHostingController?.view.topAnchor ?? view.bottomAnchor, constant: -20 ) - + subtitleBottomToSafeAreaConstraint = subtitleStackView.bottomAnchor.constraint( equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -subtitleBottomPadding ) - + NSLayoutConstraint.activate([ subtitleStackView.centerXAnchor.constraint(equalTo: view.centerXAnchor), subtitleStackView.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 36), subtitleStackView.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -36) ]) - + subtitleBottomToSafeAreaConstraint?.isActive = true } - + for _ in 0..<2 { let label = UILabel() label.textAlignment = .center @@ -759,40 +756,40 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele subtitleLabels.append(label) subtitleStackView.addArrangedSubview(label) } - + updateSubtitleLabelAppearance() } - + func updateSubtitleLabelConstraints() { if isControlsVisible { subtitleBottomToSliderConstraint?.constant = -20 } else { subtitleBottomToSafeAreaConstraint?.constant = -subtitleBottomPadding } - + view.setNeedsLayout() UIView.animate(withDuration: 0.2) { self.view.layoutIfNeeded() } } - + func setupDismissButton() { let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) let image = UIImage(systemName: "xmark", withConfiguration: config) - + dismissButton = UIButton(type: .system) dismissButton.setImage(image, for: .normal) dismissButton.tintColor = .white dismissButton.addTarget(self, action: #selector(dismissTapped), for: .touchUpInside) controlsContainerView.addSubview(dismissButton) dismissButton.translatesAutoresizingMaskIntoConstraints = false - + dismissButton.layer.shadowColor = UIColor.black.cgColor dismissButton.layer.shadowOffset = CGSize(width: 0, height: 2) dismissButton.layer.shadowOpacity = 0.6 dismissButton.layer.shadowRadius = 4 dismissButton.layer.masksToBounds = false - + NSLayoutConstraint.activate([ dismissButton.leadingAnchor.constraint(equalTo: controlsContainerView.leadingAnchor, constant: 16), dismissButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20), @@ -800,52 +797,52 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele dismissButton.heightAnchor.constraint(equalToConstant: 40) ]) } - + func setupMarqueeLabel() { marqueeLabel = MarqueeLabel() marqueeLabel.text = "\(titleText) • Ep \(episodeNumber)" marqueeLabel.type = .continuous marqueeLabel.textColor = .white marqueeLabel.font = UIFont.systemFont(ofSize: 14, weight: .heavy) - + marqueeLabel.speed = .rate(35) marqueeLabel.fadeLength = 10.0 marqueeLabel.leadingBuffer = 1.0 marqueeLabel.trailingBuffer = 16.0 marqueeLabel.animationDelay = 2.5 - + marqueeLabel.layer.shadowColor = UIColor.black.cgColor marqueeLabel.layer.shadowOffset = CGSize(width: 0, height: 2) marqueeLabel.layer.shadowOpacity = 0.6 marqueeLabel.layer.shadowRadius = 4 marqueeLabel.layer.masksToBounds = false - + marqueeLabel.lineBreakMode = .byTruncatingTail marqueeLabel.textAlignment = .left - + controlsContainerView.addSubview(marqueeLabel) marqueeLabel.translatesAutoresizingMaskIntoConstraints = false - + updateMarqueeConstraints() } - + func volumeSlider() { let container = VolumeSliderContainer(volumeVM: self.volumeViewModel) { newVal in if let sysSlider = self.systemVolumeSlider { sysSlider.value = Float(newVal) } } - + let hostingController = UIHostingController(rootView: container) hostingController.view.backgroundColor = UIColor.clear hostingController.view.translatesAutoresizingMaskIntoConstraints = false - + controlsContainerView.addSubview(hostingController.view) addChild(hostingController) hostingController.didMove(toParent: self) - + self.volumeSliderHostingView = hostingController.view - + NSLayoutConstraint.activate([ hostingController.view.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor), hostingController.view.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -16), @@ -853,68 +850,68 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele hostingController.view.heightAnchor.constraint(equalToConstant: 30) ]) } - + private func setupHoldSpeedIndicator() { let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold) let image = UIImage(systemName: "forward.fill", withConfiguration: config) var speed = UserDefaults.standard.float(forKey: "holdSpeedPlayer") - + if speed == 0.0 { speed = 2.0 } - + holdSpeedIndicator = UIButton(type: .system) holdSpeedIndicator.setTitle(" \(speed)", for: .normal) holdSpeedIndicator.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold) holdSpeedIndicator.setImage(image, for: .normal) - + holdSpeedIndicator.backgroundColor = UIColor(red: 51/255.0, green: 51/255.0, blue: 51/255.0, alpha: 0.8) holdSpeedIndicator.tintColor = .white holdSpeedIndicator.setTitleColor(.white, for: .normal) holdSpeedIndicator.layer.cornerRadius = 21 holdSpeedIndicator.alpha = 0 - + holdSpeedIndicator.layer.shadowColor = UIColor.black.cgColor holdSpeedIndicator.layer.shadowOffset = CGSize(width: 0, height: 2) holdSpeedIndicator.layer.shadowOpacity = 0.6 holdSpeedIndicator.layer.shadowRadius = 4 holdSpeedIndicator.layer.masksToBounds = false - + view.addSubview(holdSpeedIndicator) holdSpeedIndicator.translatesAutoresizingMaskIntoConstraints = false - + NSLayoutConstraint.activate([ holdSpeedIndicator.centerXAnchor.constraint(equalTo: view.centerXAnchor), holdSpeedIndicator.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20), holdSpeedIndicator.heightAnchor.constraint(equalToConstant: 40), holdSpeedIndicator.widthAnchor.constraint(greaterThanOrEqualToConstant: 85) ]) - + holdSpeedIndicator.isUserInteractionEnabled = false } - + private func updateSkipButtonsVisibility() { let t = currentTimeVal let controlsShowing = isControlsVisible - + func handle(_ button: UIButton, range: CMTimeRange?) { guard let r = range else { button.isHidden = true; return } - + let inInterval = t >= r.start.seconds && t <= r.end.seconds let target = controlsShowing ? 0.0 : skipButtonBaseAlpha - + if inInterval { if button.isHidden { button.alpha = 0 } button.isHidden = false - + UIView.animate(withDuration: 0.25) { button.alpha = target } return } - + guard !button.isHidden else { return } UIView.animate(withDuration: 0.15, animations: { button.alpha = 0 @@ -922,10 +919,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele button.isHidden = true } } - - handle(skipIntroButton, range: skipIntervals.op) - handle(skipOutroButton, range: skipIntervals.ed) - + + handle(skipIntroButton, range: skipIntervals.op) + handle(skipOutroButton, range: skipIntervals.ed) + if skipIntroDismissedInSession { skipIntroButton.isHidden = true } else { @@ -937,17 +934,17 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele handle(skipOutroButton, range: skipIntervals.ed) } } - + private func updateSegments() { sliderViewModel.introSegments.removeAll() sliderViewModel.outroSegments.removeAll() - + if let op = skipIntervals.op { let start = max(0, op.start.seconds / duration) let end = min(1, op.end.seconds / duration) sliderViewModel.introSegments.append(start...end) } - + if let ed = skipIntervals.ed { let start = max(0, ed.start.seconds / duration) let end = min(1, ed.end.seconds / duration) @@ -971,13 +968,13 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele onEditingChanged: { editing in if editing { self.isSliderEditing = true - + self.wasPlayingBeforeSeek = (self.player.timeControlStatus == .playing) self.originalRate = self.player.rate - + self.player.pause() } else { - + let target = CMTime(seconds: self.sliderViewModel.sliderValue, preferredTimescale: 600) self.player.seek( @@ -986,12 +983,12 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele toleranceAfter: .zero ) { [weak self] _ in guard let self = self else { return } - + let final = self.player.currentTime().seconds self.sliderViewModel.sliderValue = final self.currentTimeVal = final self.isSliderEditing = false - + if self.wasPlayingBeforeSeek { self.player.playImmediately(atRate: self.originalRate) } @@ -1005,7 +1002,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele ) } } - + private func fetchSkipTimes(type: String) { guard let mal = malID else { return } let url = URL(string: "https://api.aniskip.com/v2/skip-times/\(mal)/\(episodeNumber)?types=\(type)&episodeLength=0")! @@ -1014,7 +1011,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele let resp = try? JSONDecoder().decode(AniSkipResponse.self, from: d), resp.found, let interval = resp.results.first?.interval else { return } - + let range = CMTimeRange( start: CMTime(seconds: interval.startTime, preferredTimescale: 600), end: CMTime(seconds: interval.endTime, preferredTimescale: 600) @@ -1031,7 +1028,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } }.resume() } - + func setupSkipButtons() { let introConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold) let introImage = UIImage(systemName: "forward.frame", withConfiguration: introConfig) @@ -1039,9 +1036,17 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele skipIntroButton.setTitle(" Skip Intro", for: .normal) skipIntroButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold) skipIntroButton.setImage(introImage, for: .normal) - + skipIntroButton.backgroundColor = UIColor(red: 51/255.0, green: 51/255.0, blue: 51/255.0, alpha: 0.8) - skipIntroButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10) + + if #available(iOS 15.0, *) { + var config = UIButton.Configuration.filled() + config.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 10, bottom: 6, trailing: 10) + skipIntroButton.configuration = config + } else { + skipIntroButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10) + } + skipIntroButton.tintColor = .white skipIntroButton.setTitleColor(.white, for: .normal) skipIntroButton.layer.cornerRadius = 21 @@ -1057,44 +1062,53 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele skipIntroButton.layer.shadowOpacity = 0.6 skipIntroButton.layer.shadowRadius = 4 skipIntroButton.layer.masksToBounds = false - + skipIntroButton.addTarget(self, action: #selector(skipIntro), for: .touchUpInside) - + view.addSubview(skipIntroButton) skipIntroButton.translatesAutoresizingMaskIntoConstraints = false - + NSLayoutConstraint.activate([ skipIntroButton.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor), skipIntroButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5), skipIntroButton.heightAnchor.constraint(equalToConstant: 40), skipIntroButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 104) ]) - + let outroConfig = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold) let outroImage = UIImage(systemName: "forward.frame", withConfiguration: outroConfig) skipOutroButton = UIButton(type: .system) skipOutroButton.setTitle(" Skip Outro", for: .normal) skipOutroButton.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold) skipOutroButton.setImage(outroImage, for: .normal) - + skipOutroButton.backgroundColor = UIColor(red: 51/255.0, green: 51/255.0, blue: 51/255.0, alpha: 0.8) - skipOutroButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10) + + + if #available(iOS 15.0, *) { + var config = UIButton.Configuration.filled() + config.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 10, bottom: 6, trailing: 10) + skipIntroButton.configuration = config + } else { + skipIntroButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10) + } + skipOutroButton.tintColor = .white skipOutroButton.setTitleColor(.white, for: .normal) skipOutroButton.layer.cornerRadius = 21 skipOutroButton.alpha = skipButtonBaseAlpha - + skipOutroButton.layer.shadowColor = UIColor.black.cgColor skipOutroButton.layer.shadowOffset = CGSize(width: 0, height: 2) skipOutroButton.layer.shadowOpacity = 0.6 skipOutroButton.layer.shadowRadius = 4 skipOutroButton.layer.masksToBounds = false - + skipOutroButton.addTarget(self, action: #selector(skipOutro), for: .touchUpInside) - + view.addSubview(skipOutroButton) skipOutroButton.translatesAutoresizingMaskIntoConstraints = false - + NSLayoutConstraint.activate([ skipOutroButton.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor), skipOutroButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5), @@ -1102,7 +1116,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele skipOutroButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 104) ]) } - + private func setupDimButton() { let cfg = UIImage.SymbolConfiguration(pointSize: 24, weight: .regular) dimButton = UIButton(type: .system) @@ -1111,35 +1125,35 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele dimButton.addTarget(self, action: #selector(dimTapped), for: .touchUpInside) controlsContainerView.addSubview(dimButton) dimButton.translatesAutoresizingMaskIntoConstraints = false - + dimButton.layer.shadowColor = UIColor.black.cgColor dimButton.layer.shadowOffset = CGSize(width: 0, height: 2) dimButton.layer.shadowOpacity = 0.6 dimButton.layer.shadowRadius = 4 dimButton.layer.masksToBounds = false - + NSLayoutConstraint.activate([ dimButton.topAnchor.constraint(equalTo: volumeSliderHostingView!.bottomAnchor, constant: 15), dimButton.trailingAnchor.constraint(equalTo: volumeSliderHostingView!.trailingAnchor), dimButton.widthAnchor.constraint(equalToConstant: 24), dimButton.heightAnchor.constraint(equalToConstant: 24) ]) - + dimButtonToSlider = dimButton.trailingAnchor.constraint(equalTo: volumeSliderHostingView!.trailingAnchor) dimButtonToRight = dimButton.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -16) dimButtonToSlider.isActive = true } - + func updateMarqueeConstraints() { UIView.performWithoutAnimation { NSLayoutConstraint.deactivate(currentMarqueeConstraints) - + let leftSpacing: CGFloat = 2 let rightSpacing: CGFloat = 6 let trailingAnchor: NSLayoutXAxisAnchor = (volumeSliderHostingView?.isHidden == false) ? volumeSliderHostingView!.leadingAnchor : view.safeAreaLayoutGuide.trailingAnchor - + currentMarqueeConstraints = [ marqueeLabel.leadingAnchor.constraint( equalTo: dismissButton.trailingAnchor, constant: leftSpacing), @@ -1151,59 +1165,59 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele view.layoutIfNeeded() } } - + func setupMenuButton() { let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) let image = UIImage(systemName: "text.bubble", withConfiguration: config) - + menuButton = UIButton(type: .system) menuButton.setImage(image, for: .normal) menuButton.tintColor = .white - + if let subtitlesURL = subtitlesURL, !subtitlesURL.isEmpty { menuButton.showsMenuAsPrimaryAction = true menuButton.menu = buildOptionsMenu() } else { menuButton.isHidden = true } - + dismissButton.layer.shadowColor = UIColor.black.cgColor dismissButton.layer.shadowOffset = CGSize(width: 0, height: 2) dismissButton.layer.shadowOpacity = 0.6 dismissButton.layer.shadowRadius = 4 dismissButton.layer.masksToBounds = false - + controlsContainerView.addSubview(menuButton) menuButton.translatesAutoresizingMaskIntoConstraints = false - + NSLayoutConstraint.activate([ menuButton.topAnchor.constraint(equalTo: qualityButton.topAnchor), menuButton.widthAnchor.constraint(equalToConstant: 40), - menuButton.heightAnchor.constraint(equalToConstant: 40), + menuButton.heightAnchor.constraint(equalToConstant: 40) ]) - + currentMenuButtonTrailing = menuButton.trailingAnchor.constraint(equalTo: qualityButton.leadingAnchor, constant: -6) } - + func setupSpeedButton() { let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) let image = UIImage(systemName: "speedometer", withConfiguration: config) - + speedButton = UIButton(type: .system) speedButton.setImage(image, for: .normal) speedButton.tintColor = .white speedButton.showsMenuAsPrimaryAction = true speedButton.menu = speedChangerMenu() - + speedButton.layer.shadowColor = UIColor.black.cgColor speedButton.layer.shadowOffset = CGSize(width: 0, height: 2) speedButton.layer.shadowOpacity = 0.6 speedButton.layer.shadowRadius = 4 speedButton.layer.masksToBounds = false - + controlsContainerView.addSubview(speedButton) speedButton.translatesAutoresizingMaskIntoConstraints = false - + NSLayoutConstraint.activate([ speedButton.topAnchor.constraint(equalTo: watchNextButton.topAnchor), speedButton.trailingAnchor.constraint(equalTo: watchNextButton.leadingAnchor, constant: 18), @@ -1211,29 +1225,29 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele speedButton.heightAnchor.constraint(equalToConstant: 40) ]) } - + func setupWatchNextButton() { let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) let image = UIImage(systemName: "forward.end", withConfiguration: config) - + watchNextButton = UIButton(type: .system) watchNextButton.setImage(image, for: .normal) watchNextButton.backgroundColor = .clear watchNextButton.tintColor = .white watchNextButton.setTitleColor(.white, for: .normal) - + // The shadow: watchNextButton.layer.shadowColor = UIColor.black.cgColor watchNextButton.layer.shadowOffset = CGSize(width: 0, height: 2) watchNextButton.layer.shadowOpacity = 0.6 watchNextButton.layer.shadowRadius = 4 watchNextButton.layer.masksToBounds = false - + watchNextButton.addTarget(self, action: #selector(watchNextTapped), for: .touchUpInside) - + controlsContainerView.addSubview(watchNextButton) watchNextButton.translatesAutoresizingMaskIntoConstraints = false - + NSLayoutConstraint.activate([ watchNextButton.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor, constant: 20), watchNextButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5), @@ -1241,18 +1255,26 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele watchNextButton.widthAnchor.constraint(equalToConstant: 80) ]) } - + func setupSkip85Button() { let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .bold) let image = UIImage(systemName: "goforward", withConfiguration: config) - + skip85Button = UIButton(type: .system) skip85Button.setTitle(" Skip 85s", for: .normal) skip85Button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold) skip85Button.setImage(image, for: .normal) - + skip85Button.backgroundColor = UIColor(red: 51/255.0, green: 51/255.0, blue: 51/255.0, alpha: 0.8) - skip85Button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10) + + if #available(iOS 15.0, *) { + var config = UIButton.Configuration.filled() + config.contentInsets = NSDirectionalEdgeInsets(top: 6, leading: 10, bottom: 6, trailing: 10) + skipIntroButton.configuration = config + } else { + skipIntroButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10) + } + skip85Button.tintColor = .white skip85Button.setTitleColor(.white, for: .normal) skip85Button.layer.cornerRadius = 21 @@ -1268,43 +1290,42 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele skip85Button.layer.shadowOpacity = 0.6 skip85Button.layer.shadowRadius = 4 skip85Button.layer.masksToBounds = false - + skip85Button.addTarget(self, action: #selector(skip85Tapped), for: .touchUpInside) - + view.addSubview(skip85Button) skip85Button.translatesAutoresizingMaskIntoConstraints = false - + NSLayoutConstraint.activate([ skip85Button.leadingAnchor.constraint(equalTo: sliderHostingController!.view.leadingAnchor), skip85Button.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5), skip85Button.heightAnchor.constraint(equalToConstant: 40), skip85Button.widthAnchor.constraint(greaterThanOrEqualToConstant: 97) ]) - + skip85Button.isHidden = !isSkip85Visible } - - + private func setupQualityButton() { let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) let image = UIImage(systemName: "4k.tv", withConfiguration: config) - + qualityButton = UIButton(type: .system) qualityButton.setImage(image, for: .normal) qualityButton.tintColor = .white qualityButton.showsMenuAsPrimaryAction = true qualityButton.menu = qualitySelectionMenu() qualityButton.isHidden = true - + qualityButton.layer.shadowColor = UIColor.black.cgColor qualityButton.layer.shadowOffset = CGSize(width: 0, height: 2) qualityButton.layer.shadowOpacity = 0.6 qualityButton.layer.shadowRadius = 4 qualityButton.layer.masksToBounds = false - + controlsContainerView.addSubview(qualityButton) qualityButton.translatesAutoresizingMaskIntoConstraints = false - + NSLayoutConstraint.activate([ qualityButton.topAnchor.constraint(equalTo: speedButton.topAnchor), qualityButton.trailingAnchor.constraint(equalTo: speedButton.leadingAnchor, constant: -6), @@ -1312,7 +1333,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele qualityButton.heightAnchor.constraint(equalToConstant: 40) ]) } - + func updateSubtitleLabelAppearance() { for subtitleLabel in subtitleLabels { subtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize)) @@ -1328,32 +1349,31 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele subtitleLabel.layer.shadowOffset = .zero } } - + func addTimeObserver() { let interval = CMTime(seconds: 1.0, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, - queue: .main) - { [weak self] time in + queue: .main) { [weak self] time in guard let self = self, let currentItem = self.player.currentItem, currentItem.duration.seconds.isFinite else { return } - + let currentDuration = currentItem.duration.seconds if currentDuration.isNaN || currentDuration <= 0 { return } - + self.currentTimeVal = time.seconds self.duration = currentDuration self.updateSegments() - + if !self.isSliderEditing { self.sliderViewModel.sliderValue = max(0, min(self.currentTimeVal, self.duration)) } - + self.updateSkipButtonsVisibility() - + UserDefaults.standard.set(self.currentTimeVal, forKey: "lastPlayedTime_\(self.fullUrl)") UserDefaults.standard.set(self.duration, forKey: "totalTime_\(self.fullUrl)") - + if self.subtitlesEnabled { let adjustedTime = self.currentTimeVal - self.subtitleDelay let cues = self.subtitlesLoader.cues.filter { adjustedTime >= $0.startTime && adjustedTime <= $0.endTime } @@ -1377,13 +1397,13 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self.subtitleLabels[1].text = "" self.subtitleLabels[1].isHidden = true } - + let segmentsColor = self.getSegmentsColor() - + DispatchQueue.main.async { if let currentItem = self.player.currentItem, currentItem.duration.seconds > 0 { let progress = min(max(self.currentTimeVal / self.duration, 0), 1.0) - + let item = ContinueWatchingItem( id: UUID(), imageUrl: self.episodeImageUrl, @@ -1398,18 +1418,16 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele ) self.continueWatchingManager.save(item: item) } - - + let remainingPercentage = (self.duration - self.currentTimeVal) / self.duration - + if remainingPercentage < 0.1 && self.aniListID != 0 && !self.aniListUpdatedSuccessfully && - !self.aniListUpdateImpossible - { + !self.aniListUpdateImpossible { self.tryAniListUpdate() } - + self.sliderHostingController?.rootView = MusicProgressSlider( value: Binding( get: { max(0, min(self.sliderViewModel.sliderValue, self.duration)) }, @@ -1426,10 +1444,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele onEditingChanged: { editing in if editing { self.isSliderEditing = true - + self.wasPlayingBeforeSeek = (self.player.timeControlStatus == .playing) self.originalRate = self.player.rate - + self.player.pause() } else { let target = CMTime(seconds: self.sliderViewModel.sliderValue, @@ -1440,12 +1458,12 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele toleranceAfter: .zero ) { [weak self] _ in guard let self = self else { return } - + let final = self.player.currentTime().seconds self.sliderViewModel.sliderValue = final self.currentTimeVal = final self.isSliderEditing = false - + if self.wasPlayingBeforeSeek { self.player.playImmediately(atRate: self.originalRate) } @@ -1460,32 +1478,31 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } } } - + @objc private func skipIntro() { if let range = skipIntervals.op { player.seek(to: range.end) skipIntroButton.isHidden = true } } - + @objc private func skipOutro() { if let range = skipIntervals.ed { player.seek(to: range.end) skipOutroButton.isHidden = true } } - - + func startUpdateTimer() { updateTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in guard let self = self else { return } self.currentTimeVal = self.player.currentTime().seconds } } - + func updateMenuButtonConstraints() { currentMenuButtonTrailing.isActive = false - + let anchor: NSLayoutXAxisAnchor if !qualityButton.isHidden { anchor = qualityButton.leadingAnchor @@ -1494,11 +1511,11 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } else { anchor = controlsContainerView.trailingAnchor } - + currentMenuButtonTrailing = menuButton.trailingAnchor.constraint(equalTo: anchor, constant: -6) currentMenuButtonTrailing.isActive = true } - + @objc func toggleControls() { if isDimmed { dimButton.isHidden = false @@ -1516,59 +1533,59 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele let a: CGFloat = self.isControlsVisible ? 1 : 0 self.controlsContainerView.alpha = a self.skip85Button.alpha = a - + self.subtitleBottomToSafeAreaConstraint?.isActive = !self.isControlsVisible self.subtitleBottomToSliderConstraint?.isActive = self.isControlsVisible - + self.view.layoutIfNeeded() } self.updateSkipButtonsVisibility() } } - + @objc func seekBackwardLongPress(_ gesture: UILongPressGestureRecognizer) { if gesture.state == .began { let holdValue = UserDefaults.standard.double(forKey: "skipIncrementHold") let finalSkip = holdValue > 0 ? holdValue : 30 currentTimeVal = max(currentTimeVal - finalSkip, 0) - player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in + player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] _ in guard self != nil else { return } } animateButtonRotation(backwardButton, clockwise: false) } } - + @objc func seekForwardLongPress(_ gesture: UILongPressGestureRecognizer) { if gesture.state == .began { let holdValue = UserDefaults.standard.double(forKey: "skipIncrementHold") let finalSkip = holdValue > 0 ? holdValue : 30 currentTimeVal = min(currentTimeVal + finalSkip, duration) - player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in + player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] _ in guard self != nil else { return } } animateButtonRotation(forwardButton) } } - + @objc func seekBackward() { let skipValue = UserDefaults.standard.double(forKey: "skipIncrement") let finalSkip = skipValue > 0 ? skipValue : 10 currentTimeVal = max(currentTimeVal - finalSkip, 0) - player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in + player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] _ in guard self != nil else { return } } animateButtonRotation(backwardButton, clockwise: false) } - + @objc func seekForward() { let skipValue = UserDefaults.standard.double(forKey: "skipIncrement") let finalSkip = skipValue > 0 ? skipValue : 10 currentTimeVal = min(currentTimeVal + finalSkip, duration) - player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in + player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] _ in guard self != nil else { return } } animateButtonRotation(forwardButton) } - + @objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) { let tapLocation = gesture.location(in: view) if tapLocation.x < view.bounds.width / 2 { @@ -1579,17 +1596,17 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele showSkipFeedback(direction: "forward") } } - + @objc func handleSwipeDown(_ gesture: UISwipeGestureRecognizer) { dismiss(animated: true, completion: nil) } - + @objc func togglePlayPause() { if isPlaying { player.pause() isPlaying = false playPauseButton.image = UIImage(systemName: "play.fill") - + DispatchQueue.main.async { if !self.isControlsVisible { self.isControlsVisible = true @@ -1606,51 +1623,51 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele playPauseButton.image = UIImage(systemName: "pause.fill") } } - + @objc func dismissTapped() { dismiss(animated: true, completion: nil) } - + @objc func watchNextTapped() { player.pause() dismiss(animated: true) { [weak self] in self?.onWatchNext() } } - + @objc func skip85Tapped() { currentTimeVal = min(currentTimeVal + 85, duration) player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) } - + @objc private func handleHoldForPause(_ gesture: UILongPressGestureRecognizer) { guard isHoldPauseEnabled else { return } - + if gesture.state == .began { togglePlayPause() } } - + @objc private func dimTapped() { isDimmed.toggle() dimButtonTimer?.invalidate() - + UIView.animate(withDuration: 0.25) { self.blackCoverView.alpha = self.isDimmed ? 1.0 : 0.4 } - + UIView.animate(withDuration: 0.25) { for view in self.controlsToHide { view.alpha = self.isDimmed ? 0 : 1 } self.dimButton.alpha = self.isDimmed ? 0 : 1 } - + dimButtonToSlider.isActive = !isDimmed dimButtonToRight.isActive = isDimmed UIView.animate(withDuration: 0.25) { self.view.layoutIfNeeded() } } - + func speedChangerMenu() -> UIMenu { let speeds: [Double] = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0] let playbackSpeedActions = speeds.map { speed in @@ -1663,32 +1680,32 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } return UIMenu(title: "Playback Speed", children: playbackSpeedActions) } - + private func tryAniListUpdate() { let aniListMutation = AniListMutation() aniListMutation.updateAnimeProgress(animeId: self.aniListID, episodeNumber: self.episodeNumber) { [weak self] result in guard let self = self else { return } - + switch result { case .success: self.aniListUpdatedSuccessfully = true Logger.shared.log("Successfully updated AniList progress for episode \(self.episodeNumber)", type: "General") - + case .failure(let error): let errorString = error.localizedDescription.lowercased() Logger.shared.log("AniList progress update failed: \(errorString)", type: "Error") - + if errorString.contains("access token not found") { Logger.shared.log("AniList update will NOT retry due to missing token.", type: "Error") self.aniListUpdateImpossible = true - + } else { if self.aniListRetryCount < self.aniListMaxRetries { self.aniListRetryCount += 1 - + let delaySeconds = 5.0 Logger.shared.log("AniList update will retry in \(delaySeconds)s (attempt \(self.aniListRetryCount)).", type: "Debug") - + DispatchQueue.main.asyncAfter(deadline: .now() + delaySeconds) { self.tryAniListUpdate() } @@ -1699,39 +1716,38 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } } } - + private func animateButtonRotation(_ button: UIView, clockwise: Bool = true) { if button.layer.animation(forKey: "rotate360") != nil { return } button.superview?.layoutIfNeeded() - + button.layer.shouldRasterize = true button.layer.rasterizationScale = UIScreen.main.scale button.layer.allowsEdgeAntialiasing = true - + let rotation = CABasicAnimation(keyPath: "transform.rotation.z") rotation.fromValue = 0 rotation.toValue = CGFloat.pi * 2 * (clockwise ? 1 : -1) rotation.duration = 0.43 rotation.timingFunction = CAMediaTimingFunction(name: .linear) - + button.layer.add(rotation, forKey: "rotate360") - + DispatchQueue.main.asyncAfter(deadline: .now() + rotation.duration) { button.layer.shouldRasterize = false } } - - + private func parseM3U8(url: URL, completion: @escaping () -> Void) { var request = URLRequest(url: url) request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer") request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin") request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent") - - URLSession.shared.dataTask(with: request) { [weak self] data, response, error in + + URLSession.shared.dataTask(with: request) { [weak self] data, _, _ in guard let self = self, let data = data, let content = String(data: data, encoding: .utf8) else { @@ -1742,12 +1758,12 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } return } - + let lines = content.components(separatedBy: .newlines) var qualities: [(String, String)] = [] - + qualities.append(("Auto (Recommended)", url.absoluteString)) - + func getQualityName(for height: Int) -> String { switch height { case 1080...: return "\(height)p (FHD)" @@ -1756,20 +1772,20 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele default: return "\(height)p" } } - + for (index, line) in lines.enumerated() { if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count { if let resolutionRange = line.range(of: "RESOLUTION="), let resolutionEndRange = line[resolutionRange.upperBound...].range(of: ",") ?? line[resolutionRange.upperBound...].range(of: "\n") { - + let resolutionPart = String(line[resolutionRange.upperBound.. secondHeight } - + if let auto = autoQuality { sortedQualities.insert(auto, at: 0) } - + self.qualities = sortedQualities completion() } }.resume() } - + private func switchToQuality(urlString: String) { guard let url = URL(string: urlString), currentQualityURL?.absoluteString != urlString else { return } - + let currentTime = player.currentTime() let wasPlaying = player.rate > 0 - + var request = URLRequest(url: url) request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer") request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin") request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent") - + let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]]) let playerItem = AVPlayerItem(asset: asset) - + player.replaceCurrentItem(with: playerItem) player.seek(to: currentTime) if wasPlaying { player.play() } - + currentQualityURL = url - + UserDefaults.standard.set(urlString, forKey: "lastSelectedQuality") qualityButton.menu = qualitySelectionMenu() - + if let selectedQuality = qualities.first(where: { $0.1 == urlString })?.0 { DropManager.shared.showDrop(title: "Quality: \(selectedQuality)", subtitle: "", @@ -1839,10 +1855,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele icon: UIImage(systemName: "eye")) } } - + private func qualitySelectionMenu() -> UIMenu { var menuItems: [UIMenuElement] = [] - + if isHLSStream { if qualities.isEmpty { let loadingAction = UIAction(title: "Loading qualities...", attributes: .disabled) { _ in } @@ -1853,10 +1869,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele let selectedQuality = qualities.first(where: { $0.1 == currentURL })?.0 { menuTitle = "Quality: \(selectedQuality)" } - + for (name, urlString) in qualities { let isCurrentQuality = currentQualityURL?.absoluteString == urlString - + let action = UIAction( title: name, state: isCurrentQuality ? .on : .off, @@ -1866,32 +1882,32 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele ) menuItems.append(action) } - + return UIMenu(title: menuTitle, children: menuItems) } } else { let unavailableAction = UIAction(title: "Quality selection unavailable", attributes: .disabled) { _ in } menuItems.append(unavailableAction) } - + return UIMenu(title: "Video Quality", children: menuItems) } - + private func checkForHLSStream() { guard let url = URL(string: streamURL) else { return } - + if url.absoluteString.contains(".m3u8") { isHLSStream = true baseM3U8URL = url currentQualityURL = url - + parseM3U8(url: url) { [weak self] in guard let self = self else { return } if let last = UserDefaults.standard.string(forKey: "lastSelectedQuality"), self.qualities.contains(where: { $0.1 == last }) { self.switchToQuality(urlString: last) } - + self.qualityButton.isHidden = false self.qualityButton.menu = self.qualitySelectionMenu() self.updateMenuButtonConstraints() @@ -1905,16 +1921,16 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele updateMenuButtonConstraints() } } - + func buildOptionsMenu() -> UIMenu { 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" } @@ -1948,7 +1964,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } ] let colorMenu = UIMenu(title: "Subtitle Color", children: foregroundActions) - + let fontSizeActions = [ UIAction(title: "16") { _ in SubtitleSettingsManager.shared.update { settings in settings.fontSize = 16 } @@ -1978,7 +1994,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele UIAction(title: "Custom") { _ in self.presentCustomFontAlert() } ] let fontSizeMenu = UIMenu(title: "Font Size", children: fontSizeActions) - + let shadowActions = [ UIAction(title: "None") { _ in SubtitleSettingsManager.shared.update { settings in settings.shadowRadius = 0 } @@ -2002,7 +2018,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } ] let shadowMenu = UIMenu(title: "Shadow Intensity", children: shadowActions) - + let backgroundActions = [ UIAction(title: "Toggle") { _ in SubtitleSettingsManager.shared.update { settings in settings.backgroundEnabled.toggle() } @@ -2011,7 +2027,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } ] let backgroundMenu = UIMenu(title: "Background", children: backgroundActions) - + let paddingActions = [ UIAction(title: "10p") { _ in SubtitleSettingsManager.shared.update { settings in settings.bottomPadding = 10 } @@ -2028,7 +2044,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele UIAction(title: "Custom") { _ in self.presentCustomPaddingAlert() } ] let paddingMenu = UIMenu(title: "Bottom Padding", children: paddingActions) - + let delayActions = [ UIAction(title: "-0.5s") { [weak self] _ in guard let self = self else { return } @@ -2051,7 +2067,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self.presentCustomDelayAlert() } ] - + let resetDelayAction = UIAction(title: "Reset Timing") { [weak self] _ in guard let self = self else { return } SubtitleSettingsManager.shared.update { settings in settings.subtitleDelay = 0.0 } @@ -2059,19 +2075,19 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self.loadSubtitleSettings() DropManager.shared.showDrop(title: "Subtitle Timing Reset", subtitle: "", duration: 0.5, icon: UIImage(systemName: "clock.arrow.circlepath")) } - + let delayMenu = UIMenu(title: "Subtitle Timing", children: delayActions + [resetDelayAction]) - + let subtitleOptionsMenu = UIMenu(title: "Subtitle Options", children: [ subtitlesToggleAction, colorMenu, fontSizeMenu, shadowMenu, backgroundMenu, paddingMenu, delayMenu ]) - + menuElements = [subtitleOptionsMenu] } - + return UIMenu(title: "", children: menuElements) } - + func adjustSubtitleDelay(by amount: Double) { let newValue = subtitleDelay + amount let roundedValue = Double(round(newValue * 10) / 10) @@ -2079,7 +2095,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self.subtitleDelay = roundedValue self.loadSubtitleSettings() } - + func presentCustomDelayAlert() { let alert = UIAlertController(title: "Enter Custom Delay", message: nil, preferredStyle: .alert) alert.addTextField { textField in @@ -2097,7 +2113,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele }) present(alert, animated: true) } - + func presentCustomPaddingAlert() { let alert = UIAlertController(title: "Enter Custom Padding", message: nil, preferredStyle: .alert) alert.addTextField { textField in @@ -2117,7 +2133,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self.present(alert, animated: true, completion: nil) } } - + func presentCustomFontAlert() { let alert = UIAlertController(title: "Enter Custom Font Size", message: nil, preferredStyle: .alert) alert.addTextField { textField in @@ -2137,7 +2153,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self.present(alert, animated: true, completion: nil) } } - + func loadSubtitleSettings() { let settings = SubtitleSettingsManager.shared.settings self.subtitleForegroundColor = settings.foregroundColor @@ -2147,7 +2163,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self.subtitleBottomPadding = settings.bottomPadding self.subtitleDelay = settings.subtitleDelay } - + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { if UserDefaults.standard.bool(forKey: "alwaysLandscape") { return .landscape @@ -2155,15 +2171,15 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele return .all } } - + override var prefersHomeIndicatorAutoHidden: Bool { return true } - + override var prefersStatusBarHidden: Bool { return true } - + func setupAudioSession() { do { let audioSession = AVAudioSession.sharedInstance() @@ -2173,8 +2189,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } catch { Logger.shared.log("Didn't set up AVAudioSession: \(error)", type: "Debug") } - - volumeObserver = audioSession.observe(\.outputVolume, options: [.new]) { [weak self] session, change in + + volumeObserver = audioSession.observe(\.outputVolume, options: [.new]) { [weak self] _, change in guard let newVol = change.newValue else { return } if let oldVol = self?.volumeViewModel.value, abs(Double(newVol) - oldVol) < 0.02 { return @@ -2185,7 +2201,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } } } - + private func setupHoldGesture() { holdGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleHoldGesture(_:))) holdGesture?.minimumPressDuration = 0.5 @@ -2193,7 +2209,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele view.addGestureRecognizer(holdGesture) } } - + @objc private func handleHoldGesture(_ gesture: UILongPressGestureRecognizer) { switch gesture.state { case .began: @@ -2204,10 +2220,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele break } } - + @objc private func handlePanGesture(_ gesture: UIPanGestureRecognizer) { let translation = gesture.translation(in: view) - + switch gesture.state { case .ended: if translation.y > 100 { @@ -2217,34 +2233,34 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele break } } - + private func beginHoldSpeed() { guard let player = player else { return } originalRate = player.rate let holdSpeed = UserDefaults.standard.float(forKey: "holdSpeedPlayer") let speed = holdSpeed > 0 ? holdSpeed : 2.0 player.rate = speed - + UIView.animate(withDuration: 0.1) { self.holdSpeedIndicator.alpha = 0.8 } } - + private func endHoldSpeed() { player?.rate = originalRate - + UIView.animate(withDuration: 0.2) { self.holdSpeedIndicator.alpha = 0 } } - + private func setInitialPlayerRate() { if UserDefaults.standard.bool(forKey: "rememberPlaySpeed") { let lastPlayedSpeed = UserDefaults.standard.float(forKey: "lastPlaybackSpeed") player?.rate = lastPlayedSpeed > 0 ? lastPlayedSpeed : 1.0 } } - + func setupTimeControlStatusObservation() { playerTimeControlStatusObserver = player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in guard self != nil else { return } @@ -2257,11 +2273,11 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } } } - + struct VolumeSliderContainer: View { @ObservedObject var volumeVM: VolumeViewModel - var updateSystemSlider: ((Double) -> Void)? = nil - + var updateSystemSlider: ((Double) -> Void)? + var body: some View { VolumeSlider( value: Binding( @@ -2281,7 +2297,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele .shadow(color: Color.black.opacity(0.6), radius: 4, x: 0, y: 2) } } - + func subtitleUIColor() -> UIColor { switch subtitleForegroundColor { case "white": return .white diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/SubtitleSettingsManager.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/SubtitleSettingsManager.swift index 57e96b3..a8b90e1 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/SubtitleSettingsManager.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/SubtitleSettingsManager.swift @@ -18,9 +18,9 @@ struct SubtitleSettings: Codable { class SubtitleSettingsManager { static let shared = SubtitleSettingsManager() - + private let userDefaultsKey = "SubtitleSettings" - + var settings: SubtitleSettings { get { if let data = UserDefaults.standard.data(forKey: userDefaultsKey), @@ -35,7 +35,7 @@ class SubtitleSettingsManager { } } } - + func update(_ updateBlock: (inout SubtitleSettings) -> Void) { var currentSettings = settings updateBlock(¤tSettings) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/VTTSubtitlesLoader.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/VTTSubtitlesLoader.swift index d457d7a..efbf183 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/VTTSubtitlesLoader.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/VTTSubtitlesLoader.swift @@ -17,23 +17,23 @@ struct SubtitleCue: Identifiable { class VTTSubtitlesLoader: ObservableObject { @Published var cues: [SubtitleCue] = [] - + enum SubtitleFormat { case vtt case srt case unknown } - + func load(from urlString: String) { guard let url = URL(string: urlString) else { return } - + let format = determineSubtitleFormat(from: url) - + URLSession.shared.dataTask(with: url) { data, _, error in guard let data = data, let content = String(data: data, encoding: .utf8), error == nil else { return } - + DispatchQueue.main.async { switch format { case .vtt: @@ -50,7 +50,7 @@ class VTTSubtitlesLoader: ObservableObject { } }.resume() } - + private func determineSubtitleFormat(from url: URL) -> SubtitleFormat { let fileExtension = url.pathExtension.lowercased() switch fileExtension { @@ -62,31 +62,31 @@ class VTTSubtitlesLoader: ObservableObject { return .unknown } } - + private func parseVTT(content: String) -> [SubtitleCue] { var cues: [SubtitleCue] = [] let lines = content.components(separatedBy: .newlines) var index = 0 - + while index < lines.count { let line = lines[index].trimmingCharacters(in: .whitespaces) if line.isEmpty || line == "WEBVTT" { index += 1 continue } - + if !line.contains("-->") { index += 1 if index >= lines.count { break } } - + let timeLine = lines[index] let times = timeLine.components(separatedBy: "-->") if times.count < 2 { index += 1 continue } - + let startTime = parseTimecode(times[0].trimmingCharacters(in: .whitespaces)) let adjustedStartTime = max(startTime - 0.5, 0) let endTime = parseTimecode(times[1].trimmingCharacters(in: .whitespaces)) @@ -101,39 +101,39 @@ class VTTSubtitlesLoader: ObservableObject { } return cues } - + private func parseSRT(content: String) -> [SubtitleCue] { var cues: [SubtitleCue] = [] let normalizedContent = content.replacingOccurrences(of: "\r\n", with: "\n") .replacingOccurrences(of: "\r", with: "\n") let blocks = normalizedContent.components(separatedBy: "\n\n") - + for block in blocks { let lines = block.components(separatedBy: "\n").filter { !$0.isEmpty } guard lines.count >= 2 else { continue } - + let timeLine = lines[1] let times = timeLine.components(separatedBy: "-->") - + guard times.count >= 2 else { continue } - + let startTime = parseSRTTimecode(times[0].trimmingCharacters(in: .whitespaces)) let adjustedStartTime = max(startTime - 0.5, 0) let endTime = parseSRTTimecode(times[1].trimmingCharacters(in: .whitespaces)) let adjustedEndTime = max(endTime - 0.5, 0) - + var textLines = [String]() if lines.count > 2 { textLines = Array(lines[2...]) } let text = textLines.joined(separator: "\n") - + cues.append(SubtitleCue(startTime: adjustedStartTime, endTime: adjustedEndTime, text: text)) } - + return cues } - + private func parseTimecode(_ timeString: String) -> Double { let parts = timeString.components(separatedBy: ":") var seconds = 0.0 @@ -149,11 +149,11 @@ class VTTSubtitlesLoader: ObservableObject { } return seconds } - + private func parseSRTTimecode(_ timeString: String) -> Double { let parts = timeString.components(separatedBy: ":") guard parts.count == 3 else { return 0 } - + let secondsParts = parts[2].components(separatedBy: ",") guard secondsParts.count == 2, let hours = Double(parts[0]), @@ -162,7 +162,7 @@ class VTTSubtitlesLoader: ObservableObject { let milliseconds = Double(secondsParts[1]) else { return 0 } - + return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000 } } diff --git a/Sora/Utils/MediaPlayer/NormalPlayer.swift b/Sora/Utils/MediaPlayer/NormalPlayer.swift index dd8a5f6..cb937bf 100644 --- a/Sora/Utils/MediaPlayer/NormalPlayer.swift +++ b/Sora/Utils/MediaPlayer/NormalPlayer.swift @@ -10,13 +10,13 @@ import AVKit class NormalPlayer: AVPlayerViewController { private var originalRate: Float = 1.0 private var holdGesture: UILongPressGestureRecognizer? - + override func viewDidLoad() { super.viewDidLoad() setupHoldGesture() setupAudioSession() } - + private func setupHoldGesture() { holdGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleHoldGesture(_:))) holdGesture?.minimumPressDuration = 0.5 @@ -24,7 +24,7 @@ class NormalPlayer: AVPlayerViewController { view.addGestureRecognizer(holdGesture) } } - + @objc private func handleHoldGesture(_ gesture: UILongPressGestureRecognizer) { switch gesture.state { case .began: @@ -35,24 +35,24 @@ class NormalPlayer: AVPlayerViewController { break } } - + private func beginHoldSpeed() { guard let player = player else { return } originalRate = player.rate let holdSpeed = UserDefaults.standard.float(forKey: "holdSpeedPlayer") player.rate = holdSpeed > 0 ? holdSpeed : 2.0 } - + private func endHoldSpeed() { player?.rate = originalRate } - + func setupAudioSession() { do { let audioSession = AVAudioSession.sharedInstance() try audioSession.setCategory(.playback, mode: .moviePlayback, options: .mixWithOthers) try audioSession.setActive(true) - + try audioSession.overrideOutputAudioPort(.speaker) } catch { Logger.shared.log("Didn't set up AVAudioSession: \(error)", type: "Debug") diff --git a/Sora/Utils/MediaPlayer/VideoPlayer.swift b/Sora/Utils/MediaPlayer/VideoPlayer.swift index ebd2e6c..4053e89 100644 --- a/Sora/Utils/MediaPlayer/VideoPlayer.swift +++ b/Sora/Utils/MediaPlayer/VideoPlayer.swift @@ -19,41 +19,41 @@ class VideoPlayerViewController: UIViewController { var fullUrl: String = "" var subtitles: String = "" var aniListID: Int = 0 - + var episodeNumber: Int = 0 var episodeImageUrl: String = "" var mediaTitle: String = "" - + init(module: ScrapingModule, continueWatchingManager: ContinueWatchingManager) { self.module = module self.continueWatchingManager = continueWatchingManager super.init(nibName: nil, bundle: nil) } - + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { super.viewDidLoad() - + guard let streamUrl = streamUrl, let url = URL(string: streamUrl) else { return } - + var request = URLRequest(url: url) request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer") request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin") request.addValue(URLSession.randomUserAgent, forHTTPHeaderField: "User-Agent") - + let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]]) let playerItem = AVPlayerItem(asset: asset) - + player = AVPlayer(playerItem: playerItem) - + playerViewController = NormalPlayer() playerViewController?.player = player - + if let playerViewController = playerViewController { addChild(playerViewController) playerViewController.view.frame = view.bounds @@ -61,7 +61,7 @@ class VideoPlayerViewController: UIViewController { view.addSubview(playerViewController.view) playerViewController.didMove(toParent: self) } - + addPeriodicTimeObserver(fullURL: fullUrl) let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(fullUrl)") if lastPlayedTime > 0 { @@ -73,13 +73,13 @@ class VideoPlayerViewController: UIViewController { self.player?.play() } } - + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) player?.play() setInitialPlayerRate() } - + override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) if let playbackSpeed = player?.rate { @@ -91,17 +91,17 @@ class VideoPlayerViewController: UIViewController { self.timeObserverToken = nil } } - + private func setInitialPlayerRate() { if UserDefaults.standard.bool(forKey: "rememberPlaySpeed") { let lastPlayedSpeed = UserDefaults.standard.float(forKey: "lastPlaybackSpeed") player?.rate = lastPlayedSpeed > 0 ? lastPlayedSpeed : 1.0 } } - + func addPeriodicTimeObserver(fullURL: String) { guard let player = self.player else { return } - + let interval = CMTime(seconds: 1.0, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in guard let self = self, @@ -109,16 +109,16 @@ class VideoPlayerViewController: UIViewController { currentItem.duration.seconds.isFinite else { return } - + let currentTime = time.seconds let duration = currentItem.duration.seconds - + UserDefaults.standard.set(currentTime, forKey: "lastPlayedTime_\(fullURL)") UserDefaults.standard.set(duration, forKey: "totalTime_\(fullURL)") - + if let streamUrl = self.streamUrl { let progress = min(max(currentTime / duration, 0), 1.0) - + let item = ContinueWatchingItem( id: UUID(), imageUrl: self.episodeImageUrl, @@ -133,9 +133,9 @@ class VideoPlayerViewController: UIViewController { ) continueWatchingManager.save(item: item) } - + let remainingPercentage = (duration - currentTime) / duration - + if remainingPercentage < 0.1 && self.aniListID != 0 { let aniListMutation = AniListMutation() aniListMutation.updateAnimeProgress(animeId: self.aniListID, episodeNumber: self.episodeNumber) { result in @@ -149,7 +149,7 @@ class VideoPlayerViewController: UIViewController { } } } - + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { if UserDefaults.standard.bool(forKey: "alwaysLandscape") { return .landscape @@ -157,15 +157,15 @@ class VideoPlayerViewController: UIViewController { return .all } } - + override var prefersHomeIndicatorAutoHidden: Bool { return true } - + override var prefersStatusBarHidden: Bool { return true } - + deinit { player?.pause() if let timeObserverToken = timeObserverToken { diff --git a/Sora/Utils/Modules/CommunityLib.swift b/Sora/Utils/Modules/CommunityLib.swift index 10ae24f..818f088 100644 --- a/Sora/Utils/Modules/CommunityLib.swift +++ b/Sora/Utils/Modules/CommunityLib.swift @@ -71,7 +71,14 @@ struct WebView: UIViewRepresentable { func makeUIView(context: Context) -> WKWebView { let cfg = WKWebViewConfiguration() - cfg.preferences.javaScriptEnabled = true + + if #available(iOS 14.0, *) { + let webpagePreferences = WKWebpagePreferences() + webpagePreferences.allowsContentJavaScript = true + cfg.defaultWebpagePreferences = webpagePreferences + } else { + cfg.preferences.javaScriptEnabled = true + } let wv = WKWebView(frame: .zero, configuration: cfg) wv.navigationDelegate = context.coordinator return wv @@ -89,11 +96,9 @@ struct WebView: UIViewRepresentable { func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction, - decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) - { + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { if let url = action.request.url, - url.scheme == "sora", url.host == "module" - { + url.scheme == "sora", url.host == "module" { onCustom(url) decisionHandler(.cancel) } else { diff --git a/Sora/Utils/Modules/ModuleAdditionSettingsView.swift b/Sora/Utils/Modules/ModuleAdditionSettingsView.swift index fb49ef0..0984e58 100644 --- a/Sora/Utils/Modules/ModuleAdditionSettingsView.swift +++ b/Sora/Utils/Modules/ModuleAdditionSettingsView.swift @@ -11,12 +11,12 @@ import Kingfisher struct ModuleAdditionSettingsView: View { @Environment(\.presentationMode) var presentationMode @EnvironmentObject var moduleManager: ModuleManager - + @State private var moduleMetadata: ModuleMetadata? @State private var isLoading = false @State private var errorMessage: String? var moduleUrl: String - + var body: some View { VStack { ScrollView { @@ -31,15 +31,15 @@ struct ModuleAdditionSettingsView: View { .clipShape(Circle()) .shadow(radius: 5) .transition(.scale) - + Text(metadata.sourceName) .font(.system(size: 28, weight: .bold)) .multilineTextAlignment(.center) } .padding(.top) - + Divider() - + HStack(spacing: 15) { KFImage(URL(string: metadata.author.icon)) .resizable() @@ -47,7 +47,7 @@ struct ModuleAdditionSettingsView: View { .frame(width: 60, height: 60) .clipShape(Circle()) .shadow(radius: 3) - + VStack(alignment: .leading, spacing: 4) { Text(metadata.author.name) .font(.headline) @@ -58,9 +58,9 @@ struct ModuleAdditionSettingsView: View { Spacer() } .padding(.horizontal) - + Divider() - + VStack(alignment: .leading, spacing: 12) { InfoRow(title: "Version", value: metadata.version) InfoRow(title: "Language", value: metadata.language) @@ -79,9 +79,9 @@ struct ModuleAdditionSettingsView: View { } .padding(.horizontal) } - + Divider() - + } else if isLoading { VStack(spacing: 20) { ProgressView() @@ -105,9 +105,9 @@ struct ModuleAdditionSettingsView: View { } } } - + Spacer() - + VStack { Button(action: addModule) { HStack { @@ -126,7 +126,7 @@ struct ModuleAdditionSettingsView: View { } .disabled(isLoading) .opacity(isLoading ? 0.6 : 1) - + Button(action: { self.presentationMode.wrappedValue.dismiss() }) { @@ -140,11 +140,11 @@ struct ModuleAdditionSettingsView: View { .navigationTitle("Add Module") .onAppear(perform: fetchModuleMetadata) } - + private func fetchModuleMetadata() { isLoading = true errorMessage = nil - + Task { guard let url = URL(string: moduleUrl) else { await MainActor.run { @@ -169,15 +169,15 @@ struct ModuleAdditionSettingsView: View { } } } - + private func addModule() { isLoading = true Task { do { - let _ = try await moduleManager.addModule(metadataUrl: moduleUrl) + _ = try await moduleManager.addModule(metadataUrl: moduleUrl) 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")) + 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 { @@ -198,7 +198,7 @@ struct ModuleAdditionSettingsView: View { struct InfoRow: View { let title: String let value: String - + var body: some View { VStack(alignment: .leading, spacing: 4) { Text(title) diff --git a/Sora/Utils/Modules/ModuleManager.swift b/Sora/Utils/Modules/ModuleManager.swift index 33ab6f0..b76cd70 100644 --- a/Sora/Utils/Modules/ModuleManager.swift +++ b/Sora/Utils/Modules/ModuleManager.swift @@ -9,13 +9,13 @@ import Foundation class ModuleManager: ObservableObject { @Published var modules: [ScrapingModule] = [] - + private let fileManager = FileManager.default private let modulesFileName = "modules.json" - + init() { let url = getModulesFilePath() - if (!FileManager.default.fileExists(atPath: url.path)) { + if !FileManager.default.fileExists(atPath: url.path) { do { try "[]".write(to: url, atomically: true, encoding: .utf8) Logger.shared.log("Created empty modules file", type: "Info") @@ -26,27 +26,27 @@ class ModuleManager: ObservableObject { loadModules() NotificationCenter.default.addObserver(self, selector: #selector(handleModulesSyncCompleted), name: .modulesSyncDidComplete, object: nil) } - + deinit { NotificationCenter.default.removeObserver(self) } - + @objc private func handleModulesSyncCompleted() { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - + let url = self.getModulesFilePath() guard FileManager.default.fileExists(atPath: url.path) else { Logger.shared.log("No modules file found after sync", type: "Error") self.modules = [] return } - + do { let data = try Data(contentsOf: url) let decodedModules = try JSONDecoder().decode([ScrapingModule].self, from: data) self.modules = decodedModules - + Task { await self.checkJSModuleFiles() } @@ -57,18 +57,18 @@ class ModuleManager: ObservableObject { } } } - + private func getDocumentsDirectory() -> URL { fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] } - + private func getModulesFilePath() -> URL { getDocumentsDirectory().appendingPathComponent(modulesFileName) } - + func loadModules() { let url = getModulesFilePath() - + guard FileManager.default.fileExists(atPath: url.path) else { Logger.shared.log("Modules file does not exist, creating empty one", type: "Info") do { @@ -80,13 +80,13 @@ class ModuleManager: ObservableObject { } return } - + do { let data = try Data(contentsOf: url) do { let decodedModules = try JSONDecoder().decode([ScrapingModule].self, from: data) modules = decodedModules - + Task { await checkJSModuleFiles() } @@ -100,11 +100,11 @@ class ModuleManager: ObservableObject { modules = [] } } - + func checkJSModuleFiles() async { Logger.shared.log("Checking JS module files...", type: "Info") var missingCount = 0 - + for module in modules { let localUrl = getDocumentsDirectory().appendingPathComponent(module.localPath) if !fileManager.fileExists(atPath: localUrl.path) { @@ -114,15 +114,15 @@ class ModuleManager: ObservableObject { Logger.shared.log("Invalid script URL for module: \(module.metadata.sourceName)", type: "Error") continue } - + Logger.shared.log("Downloading missing JS file for: \(module.metadata.sourceName)", type: "Info") - + let (scriptData, _) = try await URLSession.custom.data(from: scriptUrl) guard let jsContent = String(data: scriptData, encoding: .utf8) else { Logger.shared.log("Invalid script encoding for module: \(module.metadata.sourceName)", type: "Error") continue } - + try jsContent.write(to: localUrl, atomically: true, encoding: .utf8) Logger.shared.log("Successfully downloaded JS file for module: \(module.metadata.sourceName)") } catch { @@ -130,14 +130,14 @@ class ModuleManager: ObservableObject { } } } - + if missingCount > 0 { Logger.shared.log("Downloaded \(missingCount) missing module JS files", type: "Info") } else { Logger.shared.log("All module JS files are present", type: "Info") } } - + private func saveModules() { DispatchQueue.main.async { let url = self.getModulesFilePath() @@ -146,80 +146,79 @@ class ModuleManager: ObservableObject { } } - func addModule(metadataUrl: String) async throws -> ScrapingModule { guard let url = URL(string: metadataUrl) else { throw NSError(domain: "Invalid metadata URL", code: -1) } - + if modules.contains(where: { $0.metadataUrl == metadataUrl }) { throw NSError(domain: "Module already exists", code: -1) } - + let (metadataData, _) = try await URLSession.custom.data(from: url) let metadata = try JSONDecoder().decode(ModuleMetadata.self, from: metadataData) - + guard let scriptUrl = URL(string: metadata.scriptUrl) else { throw NSError(domain: "Invalid script URL", code: -1) } - + let (scriptData, _) = try await URLSession.custom.data(from: scriptUrl) guard let jsContent = String(data: scriptData, encoding: .utf8) else { throw NSError(domain: "Invalid script encoding", code: -1) } - + let fileName = "\(UUID().uuidString).js" let localUrl = getDocumentsDirectory().appendingPathComponent(fileName) try jsContent.write(to: localUrl, atomically: true, encoding: .utf8) - + let module = ScrapingModule( metadata: metadata, localPath: fileName, metadataUrl: metadataUrl ) - + await MainActor.run { self.modules.append(module) self.saveModules() Logger.shared.log("Added module: \(module.metadata.sourceName)") } - + return module } - + func deleteModule(_ module: ScrapingModule) { let localUrl = getDocumentsDirectory().appendingPathComponent(module.localPath) try? fileManager.removeItem(at: localUrl) - + modules.removeAll { $0.id == module.id } saveModules() Logger.shared.log("Deleted module: \(module.metadata.sourceName)") } - + func getModuleContent(_ module: ScrapingModule) throws -> String { let localUrl = getDocumentsDirectory().appendingPathComponent(module.localPath) return try String(contentsOf: localUrl, encoding: .utf8) } - + func refreshModules() async { for (index, module) in modules.enumerated() { do { let (metadataData, _) = try await URLSession.custom.data(from: URL(string: module.metadataUrl)!) let newMetadata = try JSONDecoder().decode(ModuleMetadata.self, from: metadataData) - + if newMetadata.version != module.metadata.version { guard let scriptUrl = URL(string: newMetadata.scriptUrl) else { throw NSError(domain: "Invalid script URL", code: -1) } - + let (scriptData, _) = try await URLSession.custom.data(from: scriptUrl) guard let jsContent = String(data: scriptData, encoding: .utf8) else { throw NSError(domain: "Invalid script encoding", code: -1) } - + let localUrl = getDocumentsDirectory().appendingPathComponent(module.localPath) try jsContent.write(to: localUrl, atomically: true, encoding: .utf8) - + let updatedModule = ScrapingModule( id: module.id, metadata: newMetadata, @@ -227,12 +226,12 @@ class ModuleManager: ObservableObject { metadataUrl: module.metadataUrl, isActive: module.isActive ) - + await MainActor.run { self.modules[index] = updatedModule self.saveModules() } - + Logger.shared.log("Updated module: \(module.metadata.sourceName) to version \(newMetadata.version)") } } catch { diff --git a/Sora/Utils/Modules/Modules.swift b/Sora/Utils/Modules/Modules.swift index 7debdbb..6f59dc5 100644 --- a/Sora/Utils/Modules/Modules.swift +++ b/Sora/Utils/Modules/Modules.swift @@ -37,7 +37,7 @@ struct ScrapingModule: Codable, Identifiable, Hashable { let localPath: String let metadataUrl: String var isActive: Bool - + init(id: UUID = UUID(), metadata: ModuleMetadata, localPath: String, metadataUrl: String, isActive: Bool = false) { self.id = id self.metadata = metadata @@ -45,11 +45,11 @@ struct ScrapingModule: Codable, Identifiable, Hashable { self.metadataUrl = metadataUrl self.isActive = isActive } - + func hash(into hasher: inout Hasher) { hasher.combine(id) } - + static func == (lhs: ScrapingModule, rhs: ScrapingModule) -> Bool { lhs.id == rhs.id } diff --git a/Sora/Utils/ProfileStore/ProfileStore.swift b/Sora/Utils/ProfileStore/ProfileStore.swift index af19f63..42d7eb2 100644 --- a/Sora/Utils/ProfileStore/ProfileStore.swift +++ b/Sora/Utils/ProfileStore/ProfileStore.swift @@ -76,14 +76,14 @@ class ProfileStore: ObservableObject { } public func deleteCurrentProfile() { - if (profiles.count == 1) { return } + if profiles.count == 1 { return } if let suite = UserDefaults(suiteName: currentProfile.id.uuidString) { for key in suite.dictionaryRepresentation().keys { suite.removeObject(forKey: key) } } - + profiles.removeAll { $0.id == currentProfile.id } if let firstProfile = profiles.first { diff --git a/Sora/Utils/iCloudSyncManager/iCloudSyncManager.swift b/Sora/Utils/iCloudSyncManager/iCloudSyncManager.swift index 21f08b5..da972f9 100644 --- a/Sora/Utils/iCloudSyncManager/iCloudSyncManager.swift +++ b/Sora/Utils/iCloudSyncManager/iCloudSyncManager.swift @@ -24,7 +24,7 @@ import UIKit // TODO: tests class iCloudSyncManager { static let shared = iCloudSyncManager() - + private let defaultsToSync: [String] = [ "externalPlayer", "alwaysLandscape", @@ -51,19 +51,19 @@ class iCloudSyncManager { "profilesData", "currentProfileID" ] - + private let modulesFileName = "modules.json" - + private var ubiquityContainerURL: URL? { FileManager.default.url(forUbiquityContainerIdentifier: nil)?.appendingPathComponent("Documents") } - + private init() { setupSync() - + NotificationCenter.default.addObserver(self, selector: #selector(willEnterBackground), name: UIApplication.willResignActiveNotification, object: nil) } - + private func setupSync() { NSUbiquitousKeyValueStore.default.synchronize() syncFromiCloud() @@ -71,12 +71,12 @@ class iCloudSyncManager { NotificationCenter.default.addObserver(self, selector: #selector(iCloudDidChangeExternally), name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: NSUbiquitousKeyValueStore.default) NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange), name: UserDefaults.didChangeNotification, object: nil) } - + @objc private func willEnterBackground() { syncToiCloud() syncModulesToiCloud() } - + private func allProgressKeys() -> [String] { let allKeys = UserDefaults.standard.dictionaryRepresentation().keys let progressPrefixes = ["lastPlayedTime_", "totalTime_"] @@ -84,7 +84,7 @@ class iCloudSyncManager { progressPrefixes.contains { prefix in key.hasPrefix(prefix) } } } - + private func allKeysToSync() -> [String] { var keys = Set(defaultsToSync + allProgressKeys()) let userDefaults = UserDefaults.standard @@ -97,34 +97,34 @@ class iCloudSyncManager { } return Array(keys) } - + private func syncFromiCloud() { let iCloud = NSUbiquitousKeyValueStore.default let defaults = UserDefaults.standard - + for key in allKeysToSync() { if let value = iCloud.object(forKey: key) { defaults.set(value, forKey: key) } } - + defaults.synchronize() NotificationCenter.default.post(name: .iCloudSyncDidComplete, object: nil) } - + private func syncToiCloud() { let iCloud = NSUbiquitousKeyValueStore.default let defaults = UserDefaults.standard - + for key in allKeysToSync() { if let value = defaults.object(forKey: key) { iCloud.set(value, forKey: key) } } - + iCloud.synchronize() } - + @objc private func iCloudDidChangeExternally(_ notification: Notification) { guard let userInfo = notification.userInfo, let reason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int else { @@ -136,11 +136,11 @@ class iCloudSyncManager { syncModulesFromiCloud() } } - + @objc private func userDefaultsDidChange(_ notification: Notification) { syncToiCloud() } - + func syncModulesToiCloud() { DispatchQueue.global(qos: .background).async { guard let iCloudURL = self.ubiquityContainerURL else { return } @@ -148,7 +148,7 @@ class iCloudSyncManager { let iCloudModulesURL = iCloudURL.appendingPathComponent(self.modulesFileName) do { guard FileManager.default.fileExists(atPath: localModulesURL.path) else { return } - + let shouldCopy: Bool if FileManager.default.fileExists(atPath: iCloudModulesURL.path) { let localData = try Data(contentsOf: localModulesURL) @@ -157,7 +157,7 @@ class iCloudSyncManager { } else { shouldCopy = true } - + if shouldCopy { if FileManager.default.fileExists(atPath: iCloudModulesURL.path) { try FileManager.default.removeItem(at: iCloudModulesURL) @@ -169,20 +169,20 @@ class iCloudSyncManager { } } } - + func syncModulesFromiCloud() { guard let iCloudURL = self.ubiquityContainerURL else { Logger.shared.log("iCloud container not available", type: "Error") return } - + let localModulesURL = self.getLocalModulesFileURL() let iCloudModulesURL = iCloudURL.appendingPathComponent(self.modulesFileName) - + do { if !FileManager.default.fileExists(atPath: iCloudModulesURL.path) { Logger.shared.log("No modules file found in iCloud", type: "Info") - + if FileManager.default.fileExists(atPath: localModulesURL.path) { Logger.shared.log("Copying local modules file to iCloud", type: "Info") try FileManager.default.copyItem(at: localModulesURL, to: iCloudModulesURL) @@ -191,16 +191,16 @@ class iCloudSyncManager { let emptyModules: [ScrapingModule] = [] let emptyData = try JSONEncoder().encode(emptyModules) try emptyData.write(to: iCloudModulesURL) - + try emptyData.write(to: localModulesURL) - + DispatchQueue.main.async { NotificationCenter.default.post(name: .modulesSyncDidComplete, object: nil) } } return } - + let shouldCopy: Bool if FileManager.default.fileExists(atPath: localModulesURL.path) { let localData = try Data(contentsOf: localModulesURL) @@ -209,14 +209,14 @@ class iCloudSyncManager { } else { shouldCopy = true } - + if shouldCopy { Logger.shared.log("Syncing modules from iCloud", type: "Info") if FileManager.default.fileExists(atPath: localModulesURL.path) { try FileManager.default.removeItem(at: localModulesURL) } try FileManager.default.copyItem(at: iCloudModulesURL, to: localModulesURL) - + DispatchQueue.main.async { NotificationCenter.default.post(name: .modulesSyncDidComplete, object: nil) } @@ -225,7 +225,7 @@ class iCloudSyncManager { Logger.shared.log("iCloud modules sync error: \(error)", type: "Error") } } - + private func getLocalModulesFileURL() -> URL { let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] return docs.appendingPathComponent(modulesFileName) diff --git a/Sora/Views/DownloadView/DownloadView.swift b/Sora/Views/DownloadView/DownloadView.swift index 8717e2a..11fcd37 100644 --- a/Sora/Views/DownloadView/DownloadView.swift +++ b/Sora/Views/DownloadView/DownloadView.swift @@ -18,11 +18,11 @@ struct DownloadItem: Identifiable { class DownloadViewModel: ObservableObject { @Published var downloads: [DownloadItem] = [] - + init() { NotificationCenter.default.addObserver(self, selector: #selector(updateStatus(_:)), name: .DownloadManagerStatusUpdate, object: nil) } - + @objc func updateStatus(_ notification: Notification) { guard let info = notification.userInfo, let title = info["title"] as? String, @@ -30,7 +30,7 @@ class DownloadViewModel: ObservableObject { let type = info["type"] as? String, let status = info["status"] as? String, let progress = info["progress"] as? Double else { return } - + if let index = downloads.firstIndex(where: { $0.title == title && $0.episode == episode }) { downloads[index] = DownloadItem(title: title, episode: episode, type: type, progress: progress, status: status) } else { @@ -42,7 +42,7 @@ class DownloadViewModel: ObservableObject { struct DownloadView: View { @StateObject var viewModel = DownloadViewModel() - + var body: some View { NavigationView { List(viewModel.downloads) { download in @@ -51,15 +51,15 @@ struct DownloadView: View { .resizable() .frame(width: 30, height: 30) .foregroundColor(.accentColor) - + VStack(alignment: .leading, spacing: 4) { Text("\(download.title) - Episode \(download.episode)") .font(.headline) - + ProgressView(value: download.progress) .progressViewStyle(LinearProgressViewStyle(tint: .accentColor)) .frame(height: 8) - + Text(download.status) .font(.subheadline) .foregroundColor(.secondary) @@ -72,7 +72,7 @@ struct DownloadView: View { } .navigationViewStyle(StackNavigationViewStyle()) } - + func iconName(for download: DownloadItem) -> String { if download.type == "hls" { return download.status.lowercased().contains("converting") ? "arrow.triangle.2.circlepath.circle.fill" : "checkmark.circle.fill" diff --git a/Sora/Views/LibraryView/LibraryManager.swift b/Sora/Views/LibraryView/LibraryManager.swift index 10604b7..21697ca 100644 --- a/Sora/Views/LibraryView/LibraryManager.swift +++ b/Sora/Views/LibraryView/LibraryManager.swift @@ -15,7 +15,7 @@ struct LibraryItem: Codable, Identifiable { let moduleId: String let moduleName: String let dateAdded: Date - + init(title: String, imageUrl: String, href: String, moduleId: String, moduleName: String) { self.id = UUID() self.title = title @@ -31,7 +31,7 @@ class LibraryManager: ObservableObject { var userDefaultsSuite = UserDefaults.standard @Published var bookmarks: [LibraryItem] = [] private let bookmarksKey = "bookmarkedItems" - + init() { NotificationCenter.default.addObserver(self, selector: #selector(handleiCloudSync), name: .iCloudSyncDidComplete, object: nil) } @@ -46,16 +46,16 @@ class LibraryManager: ObservableObject { self.loadBookmarks() } } - + func removeBookmark(item: LibraryItem) { if let index = bookmarks.firstIndex(where: { $0.id == item.id }) { bookmarks.remove(at: index) - Logger.shared.log("Removed series \(item.id) from bookmarks.",type: "Debug") + Logger.shared.log("Removed series \(item.id) from bookmarks.", type: "Debug") saveBookmarks() } } - + private func loadBookmarks() { guard let data = userDefaultsSuite.data(forKey: bookmarksKey) else { Logger.shared.log("No bookmarks data found in UserDefaults.", type: "Debug") @@ -70,7 +70,7 @@ class LibraryManager: ObservableObject { bookmarks = [] } } - + private func saveBookmarks() { do { let encoded = try JSONEncoder().encode(bookmarks) @@ -79,11 +79,11 @@ class LibraryManager: ObservableObject { Logger.shared.log("Failed to save bookmarks: \(error)", type: "Error") } } - + func isBookmarked(href: String, moduleName: String) -> Bool { bookmarks.contains { $0.href == href } } - + func toggleBookmark(title: String, imageUrl: String, href: String, moduleId: String, moduleName: String) { if let index = bookmarks.firstIndex(where: { $0.href == href }) { bookmarks.remove(at: index) diff --git a/Sora/Views/LibraryView/LibraryView.swift b/Sora/Views/LibraryView/LibraryView.swift index 1f7243a..eac74ae 100644 --- a/Sora/Views/LibraryView/LibraryView.swift +++ b/Sora/Views/LibraryView/LibraryView.swift @@ -17,12 +17,12 @@ struct LibraryView: View { @AppStorage("hideEmptySections") private var hideEmptySections: Bool? @AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2 @AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4 - + @Environment(\.verticalSizeClass) var verticalSizeClass - - @State private var selectedBookmark: LibraryItem? = nil + + @State private var selectedBookmark: LibraryItem? @State private var isDetailActive: Bool = false - + @State private var continueWatchingItems: [ContinueWatchingItem] = [] @State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape @State private var showProfileSettings = false @@ -30,7 +30,7 @@ struct LibraryView: View { private let columns = [ 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 @@ -39,7 +39,7 @@ struct LibraryView: View { return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait } } - + private var cellWidth: CGFloat { let keyWindow = UIApplication.shared.connectedScenes .compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow }) } @@ -50,12 +50,12 @@ struct LibraryView: View { let availableWidth = safeWidth - totalSpacing return availableWidth / CGFloat(columnsCount) } - + var body: some View { NavigationView { ScrollView { let columnsCount = determineColumns() - + VStack(alignment: .leading, spacing: 12) { if hideEmptySections != true || !continueWatchingManager.items.isEmpty { @@ -235,7 +235,7 @@ struct LibraryView: View { updateOrientation() } } - + private func markContinueWatchingItemAsWatched(item: ContinueWatchingItem) { let key = "lastPlayedTime_\(item.fullUrl)" let totalKey = "totalTime_\(item.fullUrl)" @@ -243,17 +243,17 @@ struct LibraryView: View { UserDefaults.standard.set(99999999.0, forKey: totalKey) continueWatchingManager.remove(item: item) } - + private func removeContinueWatchingItem(item: ContinueWatchingItem) { continueWatchingManager.remove(item: item) } - + private func updateOrientation() { DispatchQueue.main.async { isLandscape = UIDevice.current.orientation.isLandscape } } - + private func determineColumns() -> Int { if UIDevice.current.userInterfaceIdiom == .pad { return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait @@ -267,7 +267,7 @@ struct ContinueWatchingSection: View { @Binding var items: [ContinueWatchingItem] var markAsWatched: (ContinueWatchingItem) -> Void var removeItem: (ContinueWatchingItem) -> Void - + var body: some View { VStack(alignment: .leading) { ScrollView(.horizontal, showsIndicators: false) { @@ -289,13 +289,13 @@ struct ContinueWatchingSection: View { struct ContinueWatchingCell: View { @EnvironmentObject private var continueWatchingManager: ContinueWatchingManager - + let item: ContinueWatchingItem var markAsWatched: () -> Void var removeItem: () -> Void - + @State private var currentProgress: Double = 0.0 - + var body: some View { Button(action: { if UserDefaults.standard.string(forKey: "externalPlayer") == "Default" { @@ -308,7 +308,7 @@ struct ContinueWatchingCell: View { videoPlayerViewController.subtitles = item.subtitles ?? "" videoPlayerViewController.aniListID = item.aniListID ?? 0 videoPlayerViewController.modalPresentationStyle = .fullScreen - + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let rootVC = windowScene.windows.first?.rootViewController { findTopViewController.findViewController(rootVC).present(videoPlayerViewController, animated: true, completion: nil) @@ -327,7 +327,7 @@ struct ContinueWatchingCell: View { episodeImageUrl: item.imageUrl ) customMediaPlayer.modalPresentationStyle = .fullScreen - + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let rootVC = windowScene.windows.first?.rootViewController { findTopViewController.findViewController(rootVC).present(customMediaPlayer, animated: true, completion: nil) @@ -364,7 +364,7 @@ struct ContinueWatchingCell: View { .fill(Color.black.opacity(0.3)) .blur(radius: 3) .frame(height: 30) - + ProgressView(value: currentProgress) .progressViewStyle(LinearProgressViewStyle(tint: .white)) .padding(.horizontal, 8) @@ -372,13 +372,13 @@ struct ContinueWatchingCell: View { }, alignment: .bottom ) - + VStack(alignment: .leading) { Text("Episode \(item.episodeNumber)") .font(.caption) .lineLimit(1) .foregroundColor(.secondary) - + Text(item.mediaTitle) .font(.caption) .lineLimit(2) @@ -403,11 +403,11 @@ struct ContinueWatchingCell: View { updateProgress() } } - + private func updateProgress() { let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(item.fullUrl)") let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(item.fullUrl)") - + if totalTime > 0 { let ratio = lastPlayedTime / totalTime // Clamp ratio between 0 and 1: diff --git a/Sora/Views/MediaInfoView/EpisodeCell/CircularProgressBar.swift b/Sora/Views/MediaInfoView/EpisodeCell/CircularProgressBar.swift index 974d1ae..4d00f0e 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/CircularProgressBar.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/CircularProgressBar.swift @@ -9,21 +9,21 @@ import SwiftUI struct CircularProgressBar: View { var progress: Double - + var body: some View { ZStack { Circle() .stroke(lineWidth: 5.0) .opacity(0.3) .foregroundColor(Color.accentColor) - + Circle() .trim(from: 0.0, to: CGFloat(min(progress, 1.0))) .stroke(style: StrokeStyle(lineWidth: 5.0, lineCap: .round, lineJoin: .round)) .foregroundColor(Color.accentColor) .rotationEffect(Angle(degrees: 270.0)) .animation(.linear, value: progress) - + if progress >= 0.9 { Image(systemName: "checkmark") .font(.system(size: 12)) diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index bf41b8b..7ba449b 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -20,25 +20,25 @@ struct EpisodeCell: View { let episodeID: Int let progress: Double let itemID: Int - + let onTap: (String) -> Void let onMarkAllPrevious: () -> Void - + @State private var episodeTitle: String = "" @State private var episodeImageUrl: String = "" @State private var isLoading: Bool = true @State private var currentProgress: Double = 0.0 - + @Environment(\.colorScheme) private var colorScheme @AppStorage("selectedAppearance") private var selectedAppearance: Appearance = .system - + var defaultBannerImage: String { let isLightMode = selectedAppearance == .light || (selectedAppearance == .system && colorScheme == .light) return isLightMode ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png" : "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner2.png" } - + init(episodeIndex: Int, episode: String, episodeID: Int, progress: Double, itemID: Int, onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void) { self.episodeIndex = episodeIndex @@ -49,7 +49,7 @@ struct EpisodeCell: View { self.onTap = onTap self.onMarkAllPrevious = onMarkAllPrevious } - + var body: some View { HStack { ZStack { @@ -58,13 +58,13 @@ struct EpisodeCell: View { .aspectRatio(16/9, contentMode: .fill) .frame(width: 100, height: 56) .cornerRadius(8) - + if isLoading { ProgressView() .progressViewStyle(CircularProgressViewStyle()) } } - + VStack(alignment: .leading) { Text("Episode \(episodeID + 1)") .font(.system(size: 15)) @@ -74,9 +74,9 @@ struct EpisodeCell: View { .foregroundColor(.secondary) } } - + Spacer() - + CircularProgressBar(progress: currentProgress) .frame(width: 40, height: 40) } @@ -87,13 +87,13 @@ struct EpisodeCell: View { Label("Mark as Watched", systemImage: "checkmark.circle") } } - + if progress != 0 { Button(action: resetProgress) { Label("Reset Progress", systemImage: "arrow.counterclockwise") } } - + if episodeIndex > 0 { Button(action: onMarkAllPrevious) { Label("Mark All Previous Watched", systemImage: "checkmark.circle.fill") @@ -112,7 +112,7 @@ struct EpisodeCell: View { onTap(imageUrl) } } - + private func markAsWatched() { let userDefaults = UserDefaults.standard let totalTime = 1000.0 @@ -123,7 +123,7 @@ struct EpisodeCell: View { self.updateProgress() } } - + private func resetProgress() { let userDefaults = UserDefaults.standard userDefaults.set(0.0, forKey: "lastPlayedTime_\(episode)") @@ -132,36 +132,36 @@ struct EpisodeCell: View { self.updateProgress() } } - + private func updateProgress() { let userDefaults = UserDefaults.standard let lastPlayedTime = userDefaults.double(forKey: "lastPlayedTime_\(episode)") let totalTime = userDefaults.double(forKey: "totalTime_\(episode)") currentProgress = totalTime > 0 ? min(lastPlayedTime / totalTime, 1.0) : 0 } - + private func fetchEpisodeDetails() { fetchAnimeEpisodeDetails() } - + private func fetchAnimeEpisodeDetails() { guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(itemID)") else { isLoading = false return } - + URLSession.custom.dataTask(with: url) { data, _, error in if let error = error { Logger.shared.log("Failed to fetch anime episode details: \(error)", type: "Error") DispatchQueue.main.async { self.isLoading = false } return } - + guard let data = data else { DispatchQueue.main.async { self.isLoading = false } return } - + do { let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) guard let json = jsonObject as? [String: Any], @@ -173,7 +173,7 @@ struct EpisodeCell: View { DispatchQueue.main.async { self.isLoading = false } return } - + DispatchQueue.main.async { self.isLoading = false if UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 871b71b..209b0a5 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -21,30 +21,30 @@ struct MediaInfoView: View { let imageUrl: String let href: String let module: ScrapingModule - + @State var aliases: String = "" @State var synopsis: String = "" @State var airdate: String = "" @State var episodeLinks: [EpisodeLink] = [] @State var itemID: Int? @State var tmdbID: Int? - + @State var isLoading: Bool = true @State var showFullSynopsis: Bool = false @State var hasFetched: Bool = false @State var isRefetching: Bool = true @State var isFetchingEpisode: Bool = false - + @State private var refreshTrigger: Bool = false @State private var buttonRefreshTrigger: Bool = false - + @State private var selectedEpisodeNumber: Int = 0 @State private var selectedEpisodeImage: String = "" @State private var selectedSeason: Int = 0 - + @AppStorage("externalPlayer") private var externalPlayer: String = "Default" @AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100 - + @StateObject private var jsController = JSController() @EnvironmentObject var moduleManager: ModuleManager @EnvironmentObject private var libraryManager: LibraryManager @@ -53,227 +53,300 @@ struct MediaInfoView: View { @State private var selectedRange: Range = 0..<100 @State private var showSettingsMenu = false @State private var customAniListID: Int? - + @State private var showStreamLoadingView: Bool = false + @State private var currentStreamTitle: String = "" + + @State private var activeFetchID: UUID? = nil + @Environment(\.dismiss) private var dismiss + @State private var orientationChanged: Bool = false - + private var isGroupedBySeasons: Bool { return groupedEpisodes().count > 1 } - + var body: some View { - Group { - if isLoading { - ProgressView() - .padding() - } else { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - HStack(alignment: .top, spacing: 10) { - KFImage(URL(string: imageUrl)) - .placeholder { - RoundedRectangle(cornerRadius: 10) - .fill(Color.gray.opacity(0.3)) - .frame(width: 150, height: 225) - .shimmering() - } - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 150, height: 225) - .clipped() - .cornerRadius(10) - - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.system(size: 17)) - .fontWeight(.bold) - .onLongPressGesture { - UIPasteboard.general.string = title - DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill")) + ZStack { + Group { + if isLoading { + ProgressView() + .padding() + } else { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 10) { + KFImage(URL(string: imageUrl)) + .placeholder { + RoundedRectangle(cornerRadius: 10) + .fill(Color.gray.opacity(0.3)) + .frame(width: 150, height: 225) + .shimmering() } - - if !aliases.isEmpty && aliases != title && aliases != "N/A" && aliases != "No Data" { - Text(aliases) - .font(.system(size: 13)) - .foregroundColor(.secondary) - } - - Spacer() - - if !airdate.isEmpty && airdate != "N/A" && airdate != "No Data" { - HStack(alignment: .center, spacing: 12) { - HStack(spacing: 4) { - Image(systemName: "calendar") - .resizable() - .frame(width: 15, height: 15) - .foregroundColor(.secondary) - - Text(airdate) - .font(.system(size: 12)) - .foregroundColor(.secondary) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 150, height: 225) + .clipped() + .cornerRadius(10) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.system(size: 17)) + .fontWeight(.bold) + .onLongPressGesture { + UIPasteboard.general.string = title + DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill")) } - .padding(4) + + if !aliases.isEmpty && aliases != title && aliases != "N/A" && aliases != "No Data" { + Text(aliases) + .font(.system(size: 13)) + .foregroundColor(.secondary) } - } - - HStack(alignment: .center, spacing: 12) { - Button(action: { - openSafariViewController(with: href) - }) { - HStack(spacing: 4) { - Text(module.metadata.sourceName) - .font(.system(size: 13)) - .foregroundColor(.primary) - - Image(systemName: "safari") + + Spacer() + + if !airdate.isEmpty && airdate != "N/A" && airdate != "No Data" { + HStack(alignment: .center, spacing: 12) { + HStack(spacing: 4) { + Image(systemName: "calendar") + .resizable() + .frame(width: 15, height: 15) + .foregroundColor(.secondary) + + Text(airdate) + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + .padding(4) + } + } + + HStack(alignment: .center, spacing: 12) { + Button(action: { + openSafariViewController(with: href) + }) { + HStack(spacing: 4) { + Text(module.metadata.sourceName) + .font(.system(size: 13)) + .foregroundColor(.primary) + + Image(systemName: "safari") + .resizable() + .frame(width: 20, height: 20) + .foregroundColor(.primary) + } + .padding(4) + .background(Capsule().fill(Color.accentColor.opacity(0.4))) + } + + Menu { + Button(action: { + showCustomIDAlert() + }) { + Label("Set Custom AniList ID", systemImage: "number") + } + + if let _ = customAniListID { + Button(action: { + customAniListID = nil + itemID = nil + fetchItemID(byTitle: cleanTitle(title)) { result in + switch result { + case .success(let id): + itemID = id + case .failure(let error): + Logger.shared.log("Failed to fetch AniList ID: \(error)") + } + } + }) { + Label("Reset AniList ID", systemImage: "arrow.clockwise") + } + } + + if let id = itemID ?? customAniListID { + Button(action: { + if let url = URL(string: "https://anilist.co/anime/\(id)") { + openSafariViewController(with: url.absoluteString) + } + }) { + Label("Open in AniList", systemImage: "link") + } + } + + Divider() + + Button(action: { + Logger.shared.log("Debug Info:\nTitle: \(title)\nHref: \(href)\nModule: \(module.metadata.sourceName)\nAniList ID: \(itemID ?? -1)\nCustom ID: \(customAniListID ?? -1)", type: "Debug") + DropManager.shared.showDrop(title: "Debug Info Logged", subtitle: "", duration: 1.0, icon: UIImage(systemName: "terminal")) + }) { + Label("Log Debug Info", systemImage: "terminal") + } + } label: { + Image(systemName: "ellipsis.circle") .resizable() .frame(width: 20, height: 20) .foregroundColor(.primary) } - .padding(4) - .background(Capsule().fill(Color.accentColor.opacity(0.4))) } - - Menu { + } + } + + if !synopsis.isEmpty { + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .center) { + Text("Synopsis") + .font(.system(size: 18)) + .fontWeight(.bold) + + Spacer() + Button(action: { - showCustomIDAlert() + showFullSynopsis.toggle() }) { - Label("Set Custom AniList ID", systemImage: "number") + Text(showFullSynopsis ? "Less" : "More") + .font(.system(size: 14)) } - - if let _ = customAniListID { - Button(action: { - customAniListID = nil - itemID = nil - fetchItemID(byTitle: cleanTitle(title)) { result in - switch result { - case .success(let id): - itemID = id - case .failure(let error): - Logger.shared.log("Failed to fetch AniList ID: \(error)") - } - } - }) { - Label("Reset AniList ID", systemImage: "arrow.clockwise") - } - } - - if let id = itemID ?? customAniListID { - Button(action: { - if let url = URL(string: "https://anilist.co/anime/\(id)") { - openSafariViewController(with: url.absoluteString) - } - }) { - Label("Open in AniList", systemImage: "link") - } - } - - Divider() - - Button(action: { - Logger.shared.log("Debug Info:\nTitle: \(title)\nHref: \(href)\nModule: \(module.metadata.sourceName)\nAniList ID: \(itemID ?? -1)\nCustom ID: \(customAniListID ?? -1)", type: "Debug") - DropManager.shared.showDrop(title: "Debug Info Logged", subtitle: "", duration: 1.0, icon: UIImage(systemName: "terminal")) - }) { - Label("Log Debug Info", systemImage: "terminal") - } - } label: { - Image(systemName: "ellipsis.circle") - .resizable() - .frame(width: 20, height: 20) + } + + Text(synopsis) + .lineLimit(showFullSynopsis ? nil : 4) + .font(.system(size: 14)) + } + } + + HStack { + Button(action: { + playFirstUnwatchedEpisode() + }) { + HStack { + Image(systemName: "play.fill") + .foregroundColor(.primary) + Text(startWatchingText) + .font(.headline) .foregroundColor(.primary) } + .padding() + .frame(maxWidth: .infinity) + .background(Color.accentColor) + .cornerRadius(10) } - } - } - - if !synopsis.isEmpty { - VStack(alignment: .leading, spacing: 2) { - HStack(alignment: .center) { - Text("Synopsis") - .font(.system(size: 18)) - .fontWeight(.bold) - - Spacer() - - Button(action: { - showFullSynopsis.toggle() - }) { - Text(showFullSynopsis ? "Less" : "More") - .font(.system(size: 14)) - } - } - - Text(synopsis) - .lineLimit(showFullSynopsis ? nil : 4) - .font(.system(size: 14)) - } - } + .disabled(isFetchingEpisode) + .id(buttonRefreshTrigger) - Button(action: { - playFirstUnwatchedEpisode() - }) { - HStack { - Image(systemName: "play.fill") - .foregroundColor(.primary) - Text(startWatchingText) - .font(.headline) - .foregroundColor(.primary) + Button(action: { + libraryManager.toggleBookmark( + title: title, + imageUrl: imageUrl, + href: href, + moduleId: module.id.uuidString, + moduleName: module.metadata.sourceName + ) + }) { + Image(systemName: libraryManager.isBookmarked(href: href, moduleName: module.metadata.sourceName) ? "bookmark.fill" : "bookmark") + .resizable() + .frame(width: 20, height: 27) + .foregroundColor(Color.accentColor) + } } - .padding() - .frame(maxWidth: .infinity) - .background(Color.accentColor) - .cornerRadius(10) - } - .disabled(isFetchingEpisode) - .id(buttonRefreshTrigger) - - if !episodeLinks.isEmpty { - VStack(alignment: .leading, spacing: 10) { - HStack { - Text("Episodes") - .font(.system(size: 18)) - .fontWeight(.bold) - - Spacer() - - if !isGroupedBySeasons, episodeLinks.count > episodeChunkSize { - Menu { - ForEach(generateRanges(), id: \.self) { range in - Button(action: { selectedRange = range }) { - Text("\(range.lowerBound + 1)-\(range.upperBound)") - } - } - } label: { - Text("\(selectedRange.lowerBound + 1)-\(selectedRange.upperBound)") - .font(.system(size: 14)) - .foregroundColor(.accentColor) - } - } else if isGroupedBySeasons { - let seasons = groupedEpisodes() - if seasons.count > 1 { + + if !episodeLinks.isEmpty { + VStack(alignment: .leading, spacing: 10) { + HStack { + Text("Episodes") + .font(.system(size: 18)) + .fontWeight(.bold) + + Spacer() + + if !isGroupedBySeasons, episodeLinks.count > episodeChunkSize { Menu { - ForEach(0.. 1 { + Menu { + ForEach(0.. 0 ? lastPlayedTime / totalTime : 0 + + EpisodeCell( + episodeIndex: selectedSeason, + episode: ep.href, + episodeID: ep.number - 1, + progress: progress, + itemID: itemID ?? 0, + onTap: { imageUrl in + if !isFetchingEpisode { + selectedEpisodeNumber = ep.number + selectedEpisodeImage = imageUrl + fetchStream(href: ep.href) + AnalyticsManager.shared.sendEvent( + event: "watch", + additionalData: ["title": title, "episode": ep.number] + ) + } + }, + onMarkAllPrevious: { + let userDefaults = UserDefaults.standard + var updates = [String: Double]() + + for ep2 in seasons[selectedSeason] where ep2.number < ep.number { + let href = ep2.href + updates["lastPlayedTime_\(href)"] = 99999999.0 + updates["totalTime_\(href)"] = 99999999.0 + } + + for (key, value) in updates { + userDefaults.set(value, forKey: key) + } + + userDefaults.synchronize() + + refreshTrigger.toggle() + Logger.shared.log("Marked episodes watched within season \(selectedSeason + 1) of \"\(title)\".", type: "General") + } + ) + .id(refreshTrigger) + .disabled(isFetchingEpisode) + } + } else { + Text("No episodes available") + } + } else { + ForEach(episodeLinks.indices.filter { selectedRange.contains($0) }, id: \.self) { i in + let ep = episodeLinks[i] let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)") let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)") let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0 - + EpisodeCell( - episodeIndex: selectedSeason, + episodeIndex: i, episode: ep.href, episodeID: ep.number - 1, progress: progress, @@ -292,152 +365,109 @@ struct MediaInfoView: View { onMarkAllPrevious: { let userDefaults = UserDefaults.standard var updates = [String: Double]() - - for ep2 in seasons[selectedSeason] where ep2.number < ep.number { - let href = ep2.href - updates["lastPlayedTime_\(href)"] = 99999999.0 - updates["totalTime_\(href)"] = 99999999.0 + + for idx in 0.. 0 ? lastPlayedTime / totalTime : 0 - - EpisodeCell( - episodeIndex: i, - episode: ep.href, - episodeID: ep.number - 1, - progress: progress, - itemID: itemID ?? 0, - onTap: { imageUrl in - if !isFetchingEpisode { - selectedEpisodeNumber = ep.number - selectedEpisodeImage = imageUrl - fetchStream(href: ep.href) - AnalyticsManager.shared.sendEvent( - event: "watch", - additionalData: ["title": title, "episode": ep.number] - ) - } - }, - onMarkAllPrevious: { - let userDefaults = UserDefaults.standard - var updates = [String: Double]() - - for idx in 0.. (finished: Int?, unfinished: Int?) { var finishedIndex: Int? = nil var firstUnfinishedIndex: Int? = nil - + for (index, ep) in episodeLinks.enumerated() { let keyLast = "lastPlayedTime_\(ep.href)" let keyTotal = "totalTime_\(ep.href)" let lastPlayedTime = UserDefaults.standard.double(forKey: keyLast) let totalTime = UserDefaults.standard.double(forKey: keyTotal) - + guard totalTime > 0 else { continue } - + let remainingFraction = (totalTime - lastPlayedTime) / totalTime if remainingFraction <= 0.1 { finishedIndex = index @@ -512,25 +568,25 @@ struct MediaInfoView: View { } return (finishedIndex, firstUnfinishedIndex) } - + private func generateRanges() -> [Range] { let chunkSize = episodeChunkSize let totalEpisodes = episodeLinks.count var ranges: [Range] = [] - + for i in stride(from: 0, to: totalEpisodes, by: chunkSize) { let end = min(i + chunkSize, totalEpisodes) ranges.append(i.. [[EpisodeLink]] { guard !episodeLinks.isEmpty else { return [] } var groups: [[EpisodeLink]] = [] var currentGroup: [EpisodeLink] = [episodeLinks[0]] - + for ep in episodeLinks.dropFirst() { if let last = currentGroup.last, ep.number < last.number { groups.append(currentGroup) @@ -539,11 +595,11 @@ struct MediaInfoView: View { currentGroup.append(ep) } } - + groups.append(currentGroup) return groups } - + func fetchDetails() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { Task { @@ -581,19 +637,24 @@ struct MediaInfoView: View { } } } - + func fetchStream(href: String) { - DropManager.shared.showDrop(title: "Fetching Stream", subtitle: "", duration: 0.5, icon: UIImage(systemName: "arrow.triangle.2.circlepath")) + let fetchID = UUID() + activeFetchID = fetchID + currentStreamTitle = "Episode \(selectedEpisodeNumber)" + showStreamLoadingView = true isFetchingEpisode = true DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { Task { do { let jsContent = try moduleManager.getModuleContent(module) jsController.loadScript(jsContent) - + if module.metadata.softsub == true { if module.metadata.asyncJS == true { jsController.fetchStreamUrlJS(episodeUrl: href, softsub: true, module: module) { result in + guard self.activeFetchID == fetchID else { return } + if let streams = result.streams, !streams.isEmpty { if streams.count > 1 { self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first) @@ -609,6 +670,8 @@ struct MediaInfoView: View { } } else if module.metadata.streamAsyncJS == true { jsController.fetchStreamUrlJSSecond(episodeUrl: href, softsub: true, module: module) { result in + guard self.activeFetchID == fetchID else { return } + if let streams = result.streams, !streams.isEmpty { if streams.count > 1 { self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first) @@ -624,6 +687,8 @@ struct MediaInfoView: View { } } else { jsController.fetchStreamUrl(episodeUrl: href, softsub: true, module: module) { result in + guard self.activeFetchID == fetchID else { return } + if let streams = result.streams, !streams.isEmpty { if streams.count > 1 { self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first) @@ -641,6 +706,8 @@ struct MediaInfoView: View { } else { if module.metadata.asyncJS == true { jsController.fetchStreamUrlJS(episodeUrl: href, module: module) { result in + guard self.activeFetchID == fetchID else { return } + if let streams = result.streams, !streams.isEmpty { if streams.count > 1 { self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first) @@ -656,6 +723,8 @@ struct MediaInfoView: View { } } else if module.metadata.streamAsyncJS == true { jsController.fetchStreamUrlJSSecond(episodeUrl: href, module: module) { result in + guard self.activeFetchID == fetchID else { return } + if let streams = result.streams, !streams.isEmpty { if streams.count > 1 { self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first) @@ -671,6 +740,8 @@ struct MediaInfoView: View { } } else { jsController.fetchStreamUrl(episodeUrl: href, module: module) { result in + guard self.activeFetchID == fetchID else { return } + if let streams = result.streams, !streams.isEmpty { if streams.count > 1 { self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first) @@ -695,29 +766,33 @@ struct MediaInfoView: View { } } } - + func handleStreamFailure(error: Error? = nil) { + self.isFetchingEpisode = false + self.showStreamLoadingView = false if let error = error { Logger.shared.log("Error loading module: \(error)", type: "Error") AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch stream"]) } DropManager.shared.showDrop(title: "Stream not Found", subtitle: "", duration: 0.5, icon: UIImage(systemName: "xmark")) - + UINotificationFeedbackGenerator().notificationOccurred(.error) self.isLoading = false } - + func showStreamSelectionAlert(streams: [String], fullURL: String, subtitles: String? = nil) { + self.isFetchingEpisode = false + self.showStreamLoadingView = false DispatchQueue.main.async { let alert = UIAlertController(title: "Select Server", message: "Choose a server to play from", preferredStyle: .actionSheet) - + var index = 0 var streamIndex = 1 - + while index < streams.count { let title: String let streamUrl: String - + if index + 1 < streams.count { if !streams[index].lowercased().contains("http") { title = streams[index] @@ -733,20 +808,20 @@ struct MediaInfoView: View { streamUrl = streams[index] index += 1 } - + alert.addAction(UIAlertAction(title: title, style: .default) { _ in self.playStream(url: streamUrl, fullURL: fullURL, subtitles: subtitles) }) - + streamIndex += 1 } - + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) - + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = windowScene.windows.first, let rootVC = window.rootViewController { - + if UIDevice.current.userInterfaceIdiom == .pad { if let popover = alert.popoverPresentationController { popover.sourceView = window @@ -759,21 +834,23 @@ struct MediaInfoView: View { popover.permittedArrowDirections = [] } } - + findTopViewController.findViewController(rootVC).present(alert, animated: true) } - + DispatchQueue.main.async { self.isFetchingEpisode = false } } } - + func playStream(url: String, fullURL: String, subtitles: String? = nil) { + self.isFetchingEpisode = false + self.showStreamLoadingView = false DispatchQueue.main.async { let externalPlayer = UserDefaults.standard.string(forKey: "externalPlayer") ?? "Sora" var scheme: String? - + switch externalPlayer { case "Infuse": scheme = "infuse://x-callback-url/play?url=\(url)" @@ -784,7 +861,10 @@ struct MediaInfoView: View { case "nPlayer": scheme = "nplayer-\(url)" case "Default": - let videoPlayerViewController = VideoPlayerViewController(module: module, continueWatchingManager: continueWatchingManager) + let videoPlayerViewController = VideoPlayerViewController( + module: module, + continueWatchingManager: continueWatchingManager + ) videoPlayerViewController.streamUrl = url videoPlayerViewController.fullUrl = fullURL videoPlayerViewController.episodeNumber = selectedEpisodeNumber @@ -793,7 +873,7 @@ struct MediaInfoView: View { videoPlayerViewController.subtitles = subtitles ?? "" videoPlayerViewController.aniListID = itemID ?? 0 videoPlayerViewController.modalPresentationStyle = .fullScreen - + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let rootVC = windowScene.windows.first?.rootViewController { findTopViewController.findViewController(rootVC).present(videoPlayerViewController, animated: true, completion: nil) @@ -802,7 +882,7 @@ struct MediaInfoView: View { default: break } - + if let scheme = scheme, let url = URL(string: scheme), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url, options: [:], completionHandler: nil) Logger.shared.log("Opening external app with scheme: \(url)", type: "General") @@ -812,7 +892,7 @@ struct MediaInfoView: View { DropManager.shared.showDrop(title: "Error", subtitle: "Invalid stream URL", duration: 2.0, icon: UIImage(systemName: "xmark.circle")) return } - + let customMediaPlayer = CustomMediaPlayerViewController( module: module, continueWatchingManager: continueWatchingManager, @@ -829,7 +909,7 @@ struct MediaInfoView: View { ) customMediaPlayer.modalPresentationStyle = .fullScreen Logger.shared.log("Opening custom media player with url: \(url)") - + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let rootVC = windowScene.windows.first?.rootViewController { findTopViewController.findViewController(rootVC).present(customMediaPlayer, animated: true, completion: nil) @@ -840,20 +920,20 @@ struct MediaInfoView: View { } } } - + private func selectNextEpisode() { guard let currentIndex = episodeLinks.firstIndex(where: { $0.number == selectedEpisodeNumber }), currentIndex + 1 < episodeLinks.count else { Logger.shared.log("No more episodes to play", type: "Info") return } - + let nextEpisode = episodeLinks[currentIndex + 1] selectedEpisodeNumber = nextEpisode.number fetchStream(href: nextEpisode.href) DropManager.shared.showDrop(title: "Fetching Next Episode", subtitle: "", duration: 0.5, icon: UIImage(systemName: "arrow.triangle.2.circlepath")) } - + private func openSafariViewController(with urlString: String) { guard let url = URL(string: urlString), UIApplication.shared.canOpenURL(url) else { Logger.shared.log("Unable to open the webpage", type: "Error") @@ -865,19 +945,19 @@ struct MediaInfoView: View { rootVC.present(safariViewController, animated: true, completion: nil) } } - + private func cleanTitle(_ title: String?) -> String { guard let title = title else { return "Unknown" } - + let cleaned = title.replacingOccurrences( of: "\\s*\\([^\\)]*\\)", with: "", options: .regularExpression ).trimmingCharacters(in: .whitespaces) - + return cleaned.isEmpty ? "Unknown" : cleaned } - + private func fetchItemID(byTitle title: String, completion: @escaping (Result) -> Void) { let query = """ query { @@ -886,30 +966,30 @@ struct MediaInfoView: View { } } """ - + guard let url = URL(string: "https://graphql.anilist.co") else { completion(.failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]))) return } - + var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") - + let parameters: [String: Any] = ["query": query] request.httpBody = try? JSONSerialization.data(withJSONObject: parameters) - + URLSession.custom.dataTask(with: request) { data, _, error in if let error = error { completion(.failure(error)) return } - + guard let data = data else { completion(.failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "No data received"]))) return } - + do { if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], let data = json["data"] as? [String: Any], @@ -925,10 +1005,10 @@ struct MediaInfoView: View { } }.resume() } - + private func showCustomIDAlert() { let alert = UIAlertController(title: "Set Custom AniList ID", message: "Enter the AniList ID for this media", preferredStyle: .alert) - + alert.addTextField { textField in textField.placeholder = "AniList ID" textField.keyboardType = .numberPad @@ -936,7 +1016,7 @@ struct MediaInfoView: View { textField.text = "\(customID)" } } - + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) alert.addAction(UIAlertAction(title: "Save", style: .default) { _ in if let text = alert.textFields?.first?.text, @@ -947,7 +1027,7 @@ struct MediaInfoView: View { Logger.shared.log("Set custom AniList ID: \(id)", type: "General") } }) - + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = windowScene.windows.first, let rootVC = window.rootViewController { diff --git a/Sora/Views/SearchView/SearchView.swift b/Sora/Views/SearchView/SearchView.swift index ba0fada..c4314af 100644 --- a/Sora/Views/SearchView/SearchView.swift +++ b/Sora/Views/SearchView/SearchView.swift @@ -15,18 +15,17 @@ struct SearchItem: Identifiable { let href: String } - struct SearchView: View { @AppStorage("hideEmptySections") private var hideEmptySections: Bool? @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 private var moduleManager: ModuleManager @EnvironmentObject private var profileStore: ProfileStore @Environment(\.verticalSizeClass) var verticalSizeClass - + @State private var searchItems: [SearchItem] = [] @State private var selectedSearchItem: SearchItem? @State private var isSearching = false @@ -40,7 +39,7 @@ struct SearchView: View { guard let id = selectedModuleId else { return nil } return moduleManager.modules.first { $0.id.uuidString == id } } - + private var loadingMessages: [String] = [ "Searching the depths...", "Looking for results...", @@ -48,7 +47,7 @@ struct SearchView: View { "Please wait...", "Almost there..." ] - + private var columnsCount: Int { if UIDevice.current.userInterfaceIdiom == .pad { let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height @@ -57,7 +56,7 @@ struct SearchView: View { return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait } } - + private var cellWidth: CGFloat { let keyWindow = UIApplication.shared.connectedScenes .compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow }) } @@ -68,7 +67,7 @@ struct SearchView: View { let availableWidth = safeWidth - totalSpacing return availableWidth / CGFloat(columnsCount) } - + var body: some View { NavigationView { ScrollView { @@ -80,7 +79,7 @@ struct SearchView: View { .padding(.trailing, searchText.isEmpty ? 16 : 0) .disabled(selectedModule == nil) .padding(.top) - + if !searchText.isEmpty { Button("Cancel") { searchText = "" @@ -90,7 +89,7 @@ struct SearchView: View { .padding(.top) } } - + if !(hideEmptySections ?? false) && selectedModule == nil { VStack(spacing: 8) { Image(systemName: "questionmark.app") @@ -106,7 +105,7 @@ struct SearchView: View { .frame(maxWidth: .infinity) .background(Color(.systemBackground)) } - + if !searchText.isEmpty { if isSearching { LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 16), count: columnsCount), spacing: 16) { @@ -162,7 +161,7 @@ struct SearchView: View { } } } - + NavigationLink( destination: SettingsViewProfile(), isActive: $showProfileSettings, @@ -263,7 +262,7 @@ struct SearchView: View { } } } - + private func performSearch() { Logger.shared.log("Searching for: \(searchText)", type: "General") guard !searchText.isEmpty, let module = selectedModule else { @@ -271,11 +270,11 @@ struct SearchView: View { hasNoResults = false return } - + isSearching = true hasNoResults = false searchItems = [] - + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { Task { do { @@ -302,13 +301,13 @@ struct SearchView: View { } } } - + private func updateOrientation() { DispatchQueue.main.async { isLandscape = UIDevice.current.orientation.isLandscape } } - + private func determineColumns() -> Int { if UIDevice.current.userInterfaceIdiom == .pad { return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait @@ -316,22 +315,22 @@ 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 { @@ -340,14 +339,14 @@ struct SearchView: View { result[language]?.append(module) } } - + return result } - + private func getModuleLanguageGroups() -> [String] { return getModulesByLanguage().keys.sorted() } - + private func getModulesForLanguage(_ language: String) -> [ScrapingModule] { return getModulesByLanguage()[language] ?? [] } @@ -357,7 +356,7 @@ struct SearchBar: View { @State private var debounceTimer: Timer? @Binding var text: String var onSearchButtonClicked: () -> Void - + var body: some View { HStack { TextField("Search...", text: $text, onCommit: onSearchButtonClicked) @@ -365,7 +364,7 @@ struct SearchBar: View { .padding(.horizontal, 25) .background(Color(.systemGray6)) .cornerRadius(8) - .onChange(of: text){newValue in + .onChange(of: text) {_ in debounceTimer?.invalidate() // Start a new timer to wait before performing the action debounceTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in @@ -379,7 +378,7 @@ struct SearchBar: View { .foregroundColor(.secondary) .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) .padding(.leading, 8) - + if !text.isEmpty { Button(action: { self.text = "" diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift index bfbf687..a32fda5 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift @@ -11,14 +11,14 @@ struct SettingsViewData: View { @State private var showEraseAppDataAlert = false @State private var showRemoveDocumentsAlert = false @State private var showSizeAlert = false - + var body: some View { Form { Section(header: Text("App storage"), footer: Text("The caches used by Sora are stored images that help load content faster\n\nThe App Data should never be erased if you dont know what that will cause.\n\nClearing the documents folder will remove all the modules and downloads")) { Button(action: clearCache) { Text("Clear Cache") } - + Button(action: { showEraseAppDataAlert = true }) { @@ -34,7 +34,7 @@ struct SettingsViewData: View { secondaryButton: .cancel() ) } - + Button(action: { showRemoveDocumentsAlert = true }) { @@ -55,7 +55,7 @@ struct SettingsViewData: View { .navigationTitle("App Data") .navigationViewStyle(StackNavigationViewStyle()) } - + func eraseAppData() { if let domain = Bundle.main.bundleIdentifier { UserDefaults.standard.removePersistentDomain(forName: domain) @@ -64,10 +64,10 @@ struct SettingsViewData: View { exit(0) } } - + func clearCache() { let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first - + do { if let cacheURL = cacheURL { let filePaths = try FileManager.default.contentsOfDirectory(at: cacheURL, includingPropertiesForKeys: nil, options: []) @@ -80,7 +80,7 @@ struct SettingsViewData: View { Logger.shared.log("Failed to clear cache.", type: "Error") } } - + func removeAllFilesInDocuments() { let fileManager = FileManager.default if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift index 0390ea9..e36593c 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift @@ -19,7 +19,7 @@ struct SettingsViewGeneral: View { @AppStorage("hideEmptySections") private var hideEmptySections: Bool = false @AppStorage("currentAppIcon") private var currentAppIcon: String = "Default" @AppStorage("episodeSortOrder") private var episodeSortOrder: String = "Ascending" - + private let metadataProvidersList = ["AniList"] private let sortOrderOptions = ["Ascending", "Descending"] @EnvironmentObject var settings: Settings @@ -73,9 +73,9 @@ struct SettingsViewGeneral: View { Toggle("Hide Empty Sections", isOn: $hideEmptySections) .tint(.accentColor) } - + Section(header: Text("Media View"), footer: Text("The episode range controls how many episodes appear on each page. Episodes are grouped into sets (like 1-25, 26-50, and so on), allowing you to navigate through them more easily.\n\nFor episode metadata it is refering to the episode thumbnail and title, since sometimes it can contain spoilers.")) { - + HStack { Text("Episodes Range") Spacer() @@ -88,10 +88,10 @@ struct SettingsViewGeneral: View { Text("\(episodeChunkSize)") } } - + Toggle("Fetch Episode metadata", isOn: $fetchEpisodeMetadata) .tint(.accentColor) - + HStack { Text("Metadata Provider") Spacer() @@ -104,7 +104,7 @@ struct SettingsViewGeneral: View { } } } - + 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 { @@ -133,12 +133,12 @@ struct SettingsViewGeneral: View { } } } - + Section(header: Text("Modules"), footer: Text("Note that the modules will be replaced only if there is a different version string inside the JSON file.")) { Toggle("Refresh Modules on Launch", isOn: $refreshModulesOnLaunch) .tint(.accentColor) } - + 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/SettingsViewLogger.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLogger.swift index 0d5c9c5..df23f30 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLogger.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLogger.swift @@ -10,7 +10,7 @@ import SwiftUI struct SettingsViewLogger: View { @State private var logs: String = "" @StateObject private var filterViewModel = LogFilterViewModel.shared - + var body: some View { VStack { ScrollView { @@ -47,7 +47,7 @@ struct SettingsViewLogger: View { .resizable() .frame(width: 20, height: 20) } - + NavigationLink(destination: SettingsViewLoggerFilter(viewModel: filterViewModel)) { Image(systemName: "slider.horizontal.3") } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLoggerFilter.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLoggerFilter.swift index 2250607..953291b 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLoggerFilter.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLoggerFilter.swift @@ -16,13 +16,13 @@ struct LogFilter: Identifiable, Hashable { class LogFilterViewModel: ObservableObject { static let shared = LogFilterViewModel() - + @Published var filters: [LogFilter] = [] { didSet { saveFiltersToUserDefaults() } } - + private let userDefaultsKey = "LogFilterStates" private let hardcodedFilters: [(type: String, description: String, defaultState: Bool)] = [ ("General", "General events and activities.", true), @@ -31,11 +31,11 @@ class LogFilterViewModel: ObservableObject { ("Debug", "Debugging and troubleshooting.", false), ("HTMLStrings", "", false) ] - + private init() { loadFilters() } - + func loadFilters() { if let savedStates = UserDefaults.standard.dictionary(forKey: userDefaultsKey) as? [String: Bool] { filters = hardcodedFilters.map { @@ -51,17 +51,17 @@ class LogFilterViewModel: ObservableObject { } } } - + func toggleFilter(for type: String) { if let index = filters.firstIndex(where: { $0.type == type }) { filters[index].isEnabled.toggle() } } - + func isFilterEnabled(for type: String) -> Bool { return filters.first(where: { $0.type == type })?.isEnabled ?? true } - + private func saveFiltersToUserDefaults() { let states = filters.reduce(into: [String: Bool]()) { result, filter in result[filter.type] = filter.isEnabled @@ -72,7 +72,7 @@ class LogFilterViewModel: ObservableObject { struct SettingsViewLoggerFilter: View { @ObservedObject var viewModel = LogFilterViewModel.shared - + var body: some View { List { ForEach($viewModel.filters) { $filter in diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift index 8cbba65..9b767cf 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift @@ -15,14 +15,14 @@ struct SettingsViewModule: View { @AppStorage("selectedModuleId") private var selectedModuleId: String? @AppStorage("hideEmptySections") private var hideEmptySections: Bool? @AppStorage("didReceiveDefaultPageLink") private var didReceiveDefaultPageLink: Bool = false - + @State private var errorMessage: String? @State private var isLoading = false @State private var isRefreshing = false @State private var moduleUrl: String = "" @State private var refreshTask: Task? @State private var showLibrary = false - + var body: some View { VStack { Form { @@ -60,7 +60,7 @@ struct SettingsViewModule: View { .frame(width: 50, height: 50) .clipShape(Circle()) .padding(.trailing, 10) - + VStack(alignment: .leading) { HStack(alignment: .bottom, spacing: 4) { Text(module.metadata.sourceName) @@ -77,9 +77,9 @@ struct SettingsViewModule: View { .font(.subheadline) .foregroundColor(.secondary) } - + Spacer() - + if module.id.uuidString == selectedModuleId { Image(systemName: "checkmark") .foregroundColor(.accentColor) @@ -181,7 +181,7 @@ struct SettingsViewModule: View { Text(errorMessage ?? "Unknown error") } } - + func showAddModuleAlert() { let pasteboardString = UIPasteboard.general.string ?? "" @@ -195,17 +195,17 @@ struct SettingsViewModule: View { clipboardAlert.addAction(UIAlertAction(title: "Use Clipboard", style: .default, handler: { _ in self.displayModuleView(url: pasteboardString) })) - + clipboardAlert.addAction(UIAlertAction(title: "Enter Manually", style: .cancel, handler: { _ in self.showManualUrlAlert() })) - + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let rootViewController = windowScene.windows.first?.rootViewController { windowScene.windows.first?.tintColor = UIColor(settings.accentColor) rootViewController.present(clipboardAlert, animated: true, completion: nil) } - + } else { showManualUrlAlert() } @@ -217,18 +217,18 @@ struct SettingsViewModule: View { message: "Enter the URL of the module file", preferredStyle: .alert ) - + alert.addTextField { textField in textField.placeholder = "https://real.url/module.json" } - + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) alert.addAction(UIAlertAction(title: "Add", style: .default, handler: { _ in if let url = alert.textFields?.first?.text, !url.isEmpty { self.displayModuleView(url: url) } })) - + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let rootViewController = windowScene.windows.first?.rootViewController { windowScene.windows.first?.tintColor = UIColor(settings.accentColor) @@ -241,7 +241,7 @@ struct SettingsViewModule: View { let addModuleView = ModuleAdditionSettingsView(moduleUrl: url) .environmentObject(self.moduleManager) let hostingController = UIHostingController(rootView: addModuleView) - + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let window = windowScene.windows.first { window.tintColor = UIColor(settings.accentColor) diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift index 394c649..3964f75 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift @@ -18,9 +18,9 @@ struct SettingsViewPlayer: View { @AppStorage("skip85Visible") private var skip85Visible: Bool = true @AppStorage("doubleTapSeekEnabled") private var doubleTapSeekEnabled: Bool = false @AppStorage("skipIntroOutroVisible") private var skipIntroOutroVisible: Bool = true - + private let mediaPlayers = ["Default", "VLC", "OutPlayer", "Infuse", "nPlayer", "Sora"] - + var body: some View { Form { Section(header: Text("Media Player"), footer: Text("Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.")) { @@ -37,14 +37,14 @@ struct SettingsViewPlayer: View { } } } - + Toggle("Force Landscape", isOn: $isAlwaysLandscape) .tint(.accentColor) - Toggle("Two Finger Hold for Pause",isOn: $holdForPauseEnabled) + Toggle("Two Finger Hold for Pause", isOn: $holdForPauseEnabled) .tint(.accentColor) } - + Section(header: Text("Speed Settings")) { Toggle("Remember Playback speed", isOn: $isRememberPlaySpeed) .tint(.accentColor) @@ -82,20 +82,20 @@ struct SettingsViewPlayer: View { } )) } - - Section(header: Text("Skip Settings"), footer : Text("Double tapping the screen on it's sides will skip with the short tap setting.")) { + + 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) } - + HStack { Text("Long press Skip:") Spacer() Stepper("\(Int(skipIncrementHold))s", value: $skipIncrementHold, in: 5...300, step: 5) } - + Toggle("Double Tap to Seek", isOn: $doubleTapSeekEnabled) .tint(.accentColor) @@ -140,7 +140,7 @@ struct SubtitleSettingsSection: View { } } } - + HStack { Text("Shadow") Spacer() @@ -157,7 +157,7 @@ struct SubtitleSettingsSection: View { } } } - + Toggle("Background Enabled", isOn: $backgroundEnabled) .tint(.accentColor) .onChange(of: backgroundEnabled) { newValue in @@ -165,7 +165,7 @@ struct SubtitleSettingsSection: View { settings.backgroundEnabled = newValue } } - + HStack { Text("Font Size:") Spacer() @@ -176,7 +176,7 @@ struct SubtitleSettingsSection: View { } } } - + HStack { Text("Bottom Padding:") Spacer() @@ -187,23 +187,23 @@ struct SubtitleSettingsSection: View { } } } - + VStack(alignment: .leading) { Text("Subtitle Delay: \(String(format: "%.1fs", subtitleDelay))") .padding(.bottom, 1) - + HStack { Text("-10s") .font(.system(size: 12)) .foregroundColor(.secondary) - + Slider(value: $subtitleDelay, in: -10...10, step: 0.1) .onChange(of: subtitleDelay) { newValue in SubtitleSettingsManager.shared.update { settings in settings.subtitleDelay = newValue } } - + Text("+10s") .font(.system(size: 12)) .foregroundColor(.secondary) diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift index c5f34f6..b1581c3 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift @@ -16,17 +16,17 @@ struct SettingsViewTrackers: View { @State private var anilistUsername: String = "" @State private var isAnilistLoading: Bool = false @State private var profileColor: Color = .accentColor - + @AppStorage("sendTraktUpdates") private var isSendTraktUpdates = true @State private var traktStatus: LocalizedStringKey = "You are not logged in" @State private var isTraktLoggedIn: Bool = false @State private var traktUsername: String = "" @State private var isTraktLoading: Bool = false - + var body: some View { Form { Section(header: Text("AniList")) { - HStack() { + HStack { KFImage(URL(string: "https://raw.githubusercontent.com/cranci1/Ryu/2f10226aa087154974a70c1ec78aa83a47daced9/Ryu/Assets.xcassets/Listing/Anilist.imageset/anilist.png")) .placeholder { RoundedRectangle(cornerRadius: 10) @@ -41,7 +41,7 @@ struct SettingsViewTrackers: View { Text("AniList.co") .font(.title2) } - + if isAnilistLoading { ProgressView() } else { @@ -58,12 +58,12 @@ struct SettingsViewTrackers: View { .multilineTextAlignment(.center) } } - + if isAnilistLoggedIn { Toggle("Sync anime progress", isOn: $isSendPushUpdates) .tint(.accentColor) } - + Button(isAnilistLoggedIn ? "Log Out from AniList" : "Log In with AniList") { if isAnilistLoggedIn { logoutAniList() @@ -73,9 +73,9 @@ struct SettingsViewTrackers: View { } .font(.body) } - + Section(header: Text("Trakt")) { - HStack() { + HStack { KFImage(URL(string: "https://static-00.iconduck.com/assets.00/trakt-icon-2048x2048-2633ksxg.png")) .placeholder { RoundedRectangle(cornerRadius: 10) @@ -90,7 +90,7 @@ struct SettingsViewTrackers: View { Text("Trakt.tv") .font(.title2) } - + if isTraktLoading { ProgressView() } else { @@ -106,7 +106,7 @@ struct SettingsViewTrackers: View { .multilineTextAlignment(.center) } } - + Button(isTraktLoggedIn ? "Log Out from Trakt" : "Log In with Trakt") { if isTraktLoggedIn { logoutTrakt() @@ -116,7 +116,7 @@ struct SettingsViewTrackers: View { } .font(.body) } - + Section(footer: Text("Sora and cranci1 are not affiliated with AniList nor Trakt in any way.\n\nAlso note that progresses update may not be 100% accurate.")) {} } .navigationTitle("Trackers") @@ -129,21 +129,21 @@ struct SettingsViewTrackers: View { removeNotificationObservers() } } - + func removeNotificationObservers() { NotificationCenter.default.removeObserver(self, name: AniListToken.authSuccessNotification, object: nil) NotificationCenter.default.removeObserver(self, name: AniListToken.authFailureNotification, object: nil) - + NotificationCenter.default.removeObserver(self, name: TraktToken.authSuccessNotification, object: nil) NotificationCenter.default.removeObserver(self, name: TraktToken.authFailureNotification, object: nil) } - + func setupNotificationObservers() { NotificationCenter.default.addObserver(forName: AniListToken.authSuccessNotification, object: nil, queue: .main) { _ in self.anilistStatus = "Authentication successful!" self.updateAniListStatus() } - + NotificationCenter.default.addObserver(forName: AniListToken.authFailureNotification, object: nil, queue: .main) { notification in if let error = notification.userInfo?["error"] as? String { self.anilistStatus = "Login failed: \(error)" @@ -153,12 +153,12 @@ struct SettingsViewTrackers: View { self.isAnilistLoggedIn = false self.isAnilistLoading = false } - + NotificationCenter.default.addObserver(forName: TraktToken.authSuccessNotification, object: nil, queue: .main) { _ in self.traktStatus = "Authentication successful!" self.updateTraktStatus() } - + NotificationCenter.default.addObserver(forName: TraktToken.authFailureNotification, object: nil, queue: .main) { notification in if let error = notification.userInfo?["error"] as? String { self.traktStatus = "Login failed: \(error)" @@ -169,20 +169,20 @@ struct SettingsViewTrackers: View { self.isTraktLoading = false } } - + func loginTrakt() { traktStatus = "Starting authentication..." isTraktLoading = true TraktLogin.authenticate() } - + func logoutTrakt() { removeTraktTokenFromKeychain() traktStatus = "You are not logged in" isTraktLoggedIn = false traktUsername = "" } - + func updateTraktStatus() { if let token = getTraktTokenFromKeychain() { isTraktLoggedIn = true @@ -192,7 +192,7 @@ struct SettingsViewTrackers: View { traktStatus = "You are not logged in" } } - + func fetchTraktUserInfo(token: String) { isTraktLoading = true let userInfoURL = URL(string: "https://api.trakt.tv/users/settings")! @@ -202,20 +202,20 @@ struct SettingsViewTrackers: View { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.setValue("2", forHTTPHeaderField: "trakt-api-version") request.setValue(TraktToken.clientID, forHTTPHeaderField: "trakt-api-key") - - URLSession.shared.dataTask(with: request) { data, response, error in + + URLSession.shared.dataTask(with: request) { data, _, error in DispatchQueue.main.async { self.isTraktLoading = false if let error = error { self.traktStatus = "Error: \(error.localizedDescription)" return } - + guard let data = data else { self.traktStatus = "No data received" return } - + do { if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], let user = json["user"] as? [String: Any], @@ -229,7 +229,7 @@ struct SettingsViewTrackers: View { } }.resume() } - + func getTraktTokenFromKeychain() -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -238,7 +238,7 @@ struct SettingsViewTrackers: View { kSecReturnData as String: kCFBooleanTrue!, kSecMatchLimit as String: kSecMatchLimitOne ] - + var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) guard status == errSecSuccess, @@ -248,7 +248,7 @@ struct SettingsViewTrackers: View { } return token } - + func removeTraktTokenFromKeychain() { let deleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -256,7 +256,7 @@ struct SettingsViewTrackers: View { kSecAttrAccount as String: TraktToken.accessTokenKey ] SecItemDelete(deleteQuery as CFDictionary) - + let refreshDeleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: TraktToken.serviceName, @@ -264,13 +264,13 @@ struct SettingsViewTrackers: View { ] SecItemDelete(refreshDeleteQuery as CFDictionary) } - + func loginAniList() { anilistStatus = "Starting authentication..." isAnilistLoading = true AniListLogin.authenticate() } - + func logoutAniList() { removeTokenFromKeychain() anilistStatus = "You are not logged in" @@ -278,7 +278,7 @@ struct SettingsViewTrackers: View { anilistUsername = "" profileColor = .primary } - + func updateAniListStatus() { if let token = getTokenFromKeychain() { isAnilistLoggedIn = true @@ -288,7 +288,7 @@ struct SettingsViewTrackers: View { anilistStatus = "You are not logged in" } } - + func fetchUserInfo(token: String) { isAnilistLoading = true let userInfoURL = URL(string: "https://graphql.anilist.co")! @@ -296,7 +296,7 @@ struct SettingsViewTrackers: View { request.httpMethod = "POST" request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") - + let query = """ { Viewer { @@ -309,7 +309,7 @@ struct SettingsViewTrackers: View { } """ let body: [String: Any] = ["query": query] - + do { request.httpBody = try JSONSerialization.data(withJSONObject: body, options: []) } catch { @@ -318,8 +318,8 @@ struct SettingsViewTrackers: View { isAnilistLoading = false return } - - URLSession.shared.dataTask(with: request) { data, response, error in + + URLSession.shared.dataTask(with: request) { data, _, error in DispatchQueue.main.async { isAnilistLoading = false if let error = error { @@ -339,7 +339,7 @@ struct SettingsViewTrackers: View { let name = viewer["name"] as? String, let options = viewer["options"] as? [String: Any], let colorName = options["profileColor"] as? String { - + anilistUsername = name profileColor = colorFromName(colorName) anilistStatus = "Logged in as \(name)" @@ -354,7 +354,7 @@ struct SettingsViewTrackers: View { } }.resume() } - + func colorFromName(_ name: String) -> Color { switch name.lowercased() { case "blue": @@ -375,7 +375,7 @@ struct SettingsViewTrackers: View { return .accentColor } } - + func getTokenFromKeychain() -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, @@ -391,7 +391,7 @@ struct SettingsViewTrackers: View { } return String(data: tokenData, encoding: .utf8) } - + func removeTokenFromKeychain() { let deleteQuery: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, diff --git a/Sora/Views/SettingsView/SettingsView.swift b/Sora/Views/SettingsView/SettingsView.swift index cd3dd6d..f54a7e6 100644 --- a/Sora/Views/SettingsView/SettingsView.swift +++ b/Sora/Views/SettingsView/SettingsView.swift @@ -34,7 +34,7 @@ struct SettingsView: View { Text("Trackers") } } - + Section(header: Text("Diagnostics & Storage")) { NavigationLink(destination: SettingsViewData()) { Text("Data") @@ -43,7 +43,7 @@ struct SettingsView: View { Text("Logs") } } - + Section( header: Text("Info"), footer: Text("Running Sora \(version) - cranci1") @@ -123,7 +123,7 @@ struct SettingsView: View { enum Appearance: String, CaseIterable, Identifiable { case system, light, dark - + var id: String { self.rawValue } } @@ -145,7 +145,7 @@ class Settings: ObservableObject { updateAppearance() } } - + init() { if let shimmerRawValue = UserDefaults.standard.string(forKey: "shimmerType"), let shimmer = ShimmerType(rawValue: shimmerRawValue) { @@ -161,7 +161,6 @@ class Settings: ObservableObject { self.accentColor = .accentColor } - if let appearanceRawValue = UserDefaults.standard.string(forKey: "selectedAppearance"), let appearance = Appearance(rawValue: appearanceRawValue) { self.selectedAppearance = appearance @@ -188,7 +187,7 @@ class Settings: ObservableObject { Logger.shared.log("Failed to save accent color: \(error.localizedDescription)") } } - + func updateAppearance() { guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return } switch selectedAppearance { diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 03598d5..29c9c05 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -106,13 +106,13 @@ buildConfigurationList = 1207646F2DB6F6E1003621E9 /* Build configuration list for PBXNativeTarget "SulfurTV" */; buildPhases = ( 120764612DB6F6E0003621E9 /* Sources */, + 12DAC1832DBE3C1C00B31A65 /* ShellScript */, 120764622DB6F6E0003621E9 /* Frameworks */, 120764632DB6F6E0003621E9 /* Resources */, ); buildRules = ( ); dependencies = ( - 120D3C702DBA3DF90093D596 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 120764662DB6F6E0003621E9 /* SulfurTV */, @@ -129,13 +129,13 @@ buildConfigurationList = 133D7C782D2BE2520075467E /* Build configuration list for PBXNativeTarget "Sulfur" */; buildPhases = ( 133D7C662D2BE2500075467E /* Sources */, + 12DAC1822DBE3C0300B31A65 /* ShellScript */, 133D7C672D2BE2500075467E /* Frameworks */, 133D7C682D2BE2500075467E /* Resources */, ); buildRules = ( ); dependencies = ( - 120D3C6E2DBA3DF30093D596 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 126C42F62DB9AA97006BC27D /* Sora */, @@ -184,7 +184,6 @@ 132E351E2D959E1D0007800E /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */, 132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */, 13B77E172DA44F8300126FDF /* XCRemoteSwiftPackageReference "MarqueeLabel" */, - 120D3C6C2DBA3D790093D596 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */, ); productRefGroup = 133D7C6B2D2BE2500075467E /* Products */; projectDirPath = ""; @@ -217,6 +216,43 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 12DAC1822DBE3C0300B31A65 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if command -v swiftlint >/dev/null 2>&1\nthen\n swiftlint --fix && swiftlint\nelse\n echo \"warning: `swiftlint` command not found - See https://github.com/realm/SwiftLint#installation for installation instructions.\"\nfi\n"; + }; + 12DAC1832DBE3C1C00B31A65 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if command -v swiftlint >/dev/null 2>&1\nthen\n swiftlint --fix && swiftlint\nelse\n echo \"warning: `swiftlint` command not found - See https://github.com/realm/SwiftLint#installation for installation instructions.\"\nfi\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 120764612DB6F6E0003621E9 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -234,17 +270,6 @@ }; /* End PBXSourcesBuildPhase section */ -/* Begin PBXTargetDependency section */ - 120D3C6E2DBA3DF30093D596 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - productRef = 120D3C6D2DBA3DF30093D596 /* SwiftLintBuildToolPlugin */; - }; - 120D3C702DBA3DF90093D596 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - productRef = 120D3C6F2DBA3DF90093D596 /* SwiftLintBuildToolPlugin */; - }; -/* End PBXTargetDependency section */ - /* Begin XCBuildConfiguration section */ 1207646D2DB6F6E1003621E9 /* Debug */ = { isa = XCBuildConfiguration; @@ -575,14 +600,6 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 120D3C6C2DBA3D790093D596 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/SimplyDanny/SwiftLintPlugins"; - requirement = { - branch = main; - kind = branch; - }; - }; 132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/omaralbeik/Drops.git"; @@ -618,16 +635,6 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 120D3C6D2DBA3DF30093D596 /* SwiftLintBuildToolPlugin */ = { - isa = XCSwiftPackageProductDependency; - package = 120D3C6C2DBA3D790093D596 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */; - productName = "plugin:SwiftLintBuildToolPlugin"; - }; - 120D3C6F2DBA3DF90093D596 /* SwiftLintBuildToolPlugin */ = { - isa = XCSwiftPackageProductDependency; - package = 120D3C6C2DBA3D790093D596 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */; - productName = "plugin:SwiftLintBuildToolPlugin"; - }; 132E351C2D959DDB0007800E /* Drops */ = { isa = XCSwiftPackageProductDependency; package = 132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */; diff --git a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 5525459..d147912 100644 --- a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c4909124df3eb22bfcc539fb1f3936eb79c309605d2f34c5b02efa1fe3f48447", + "originHash" : "e772caa8d6a8793d24bf04e3d77695cd5ac695f3605d2b657e40115caedf8863", "pins" : [ { "identity" : "drops", @@ -45,15 +45,6 @@ "branch" : "master", "revision" : "18e4787f4dc1c26d2d581c4bc9aeae34686eeeae" } - }, - { - "identity" : "swiftlintplugins", - "kind" : "remoteSourceControl", - "location" : "https://github.com/SimplyDanny/SwiftLintPlugins", - "state" : { - "branch" : "main", - "revision" : "8545ddf4de043e6f2051c5cf204f39ef778ebf6b" - } } ], "version" : 3 diff --git a/SulfurTV/Views/SettingsView/SettingsSubViews/InfoView.swift b/SulfurTV/Views/SettingsView/SettingsSubViews/InfoView.swift index 47d7e61..3bf65d4 100644 --- a/SulfurTV/Views/SettingsView/SettingsSubViews/InfoView.swift +++ b/SulfurTV/Views/SettingsView/SettingsSubViews/InfoView.swift @@ -26,19 +26,19 @@ struct InfoView: View { .background(.primary) .cornerRadius(30) } - + // Text + Close Button VStack(alignment: .leading, spacing: 40) { Text("Scan to Visit") .font(.largeTitle) .bold() .foregroundColor(.primary) - + Text("Links cannot be openend on tvOS. But you can either Scan the QR-Code or type in the following URL into the Browser of your choosing: \n\n\(urlString)") .font(.subheadline) .foregroundColor(.secondary) .frame(maxWidth: 500) - + Button("Close") { dismiss() }