diff --git a/README.md b/README.md index a10d61d..645edd6 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ An iOS and macOS modular web scraping app, under the GPLv3.0 License. - [x] Local Library - [x] Streams support (Jellyfin/Plex like servers) - [x] External Media players (VLC, infuse, Outplayer, nPlayer) +- [x] Tracking Services (AniList, Trakt) ## Frequently Asked Questions diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/1024.png b/Sora/Assets.xcassets/AppIcon.appiconset/1024.png deleted file mode 100644 index 63936e4..0000000 Binary files a/Sora/Assets.xcassets/AppIcon.appiconset/1024.png and /dev/null differ diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/120-1.png b/Sora/Assets.xcassets/AppIcon.appiconset/120-1.png deleted file mode 100644 index 1cbc052..0000000 Binary files a/Sora/Assets.xcassets/AppIcon.appiconset/120-1.png and /dev/null differ diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/120.png b/Sora/Assets.xcassets/AppIcon.appiconset/120.png deleted file mode 100644 index 1cbc052..0000000 Binary files a/Sora/Assets.xcassets/AppIcon.appiconset/120.png and /dev/null differ diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/152.png b/Sora/Assets.xcassets/AppIcon.appiconset/152.png deleted file mode 100644 index 953db5a..0000000 Binary files a/Sora/Assets.xcassets/AppIcon.appiconset/152.png and /dev/null differ diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/167.png b/Sora/Assets.xcassets/AppIcon.appiconset/167.png deleted file mode 100644 index 6e41ff7..0000000 Binary files a/Sora/Assets.xcassets/AppIcon.appiconset/167.png and /dev/null differ diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/180.png b/Sora/Assets.xcassets/AppIcon.appiconset/180.png deleted file mode 100644 index 394bb7b..0000000 Binary files a/Sora/Assets.xcassets/AppIcon.appiconset/180.png and /dev/null differ diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/20.png b/Sora/Assets.xcassets/AppIcon.appiconset/20.png deleted file mode 100644 index 3dbb016..0000000 Binary files a/Sora/Assets.xcassets/AppIcon.appiconset/20.png and /dev/null differ diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/29.png b/Sora/Assets.xcassets/AppIcon.appiconset/29.png deleted file mode 100644 index 55dd67d..0000000 Binary files a/Sora/Assets.xcassets/AppIcon.appiconset/29.png and /dev/null differ diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/40-1.png b/Sora/Assets.xcassets/AppIcon.appiconset/40-1.png deleted file mode 100644 index 1e9c473..0000000 Binary files a/Sora/Assets.xcassets/AppIcon.appiconset/40-1.png and /dev/null differ diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/40-2.png b/Sora/Assets.xcassets/AppIcon.appiconset/40-2.png deleted file mode 100644 index 1e9c473..0000000 Binary files a/Sora/Assets.xcassets/AppIcon.appiconset/40-2.png and /dev/null differ diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/40.png b/Sora/Assets.xcassets/AppIcon.appiconset/40.png deleted file mode 100644 index 1e9c473..0000000 Binary files a/Sora/Assets.xcassets/AppIcon.appiconset/40.png and /dev/null differ diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/58-1.png b/Sora/Assets.xcassets/AppIcon.appiconset/58-1.png deleted file mode 100644 index 25a3dec..0000000 Binary files a/Sora/Assets.xcassets/AppIcon.appiconset/58-1.png and /dev/null differ diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/58.png b/Sora/Assets.xcassets/AppIcon.appiconset/58.png deleted file mode 100644 index 25a3dec..0000000 Binary files a/Sora/Assets.xcassets/AppIcon.appiconset/58.png and /dev/null differ diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/60.png b/Sora/Assets.xcassets/AppIcon.appiconset/60.png deleted file mode 100644 index d007118..0000000 Binary files a/Sora/Assets.xcassets/AppIcon.appiconset/60.png and /dev/null differ diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/76.png b/Sora/Assets.xcassets/AppIcon.appiconset/76.png deleted file mode 100644 index 6648094..0000000 Binary files a/Sora/Assets.xcassets/AppIcon.appiconset/76.png and /dev/null differ diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/80-1.png b/Sora/Assets.xcassets/AppIcon.appiconset/80-1.png deleted file mode 100644 index b048379..0000000 Binary files a/Sora/Assets.xcassets/AppIcon.appiconset/80-1.png and /dev/null differ diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/80.png b/Sora/Assets.xcassets/AppIcon.appiconset/80.png deleted file mode 100644 index b048379..0000000 Binary files a/Sora/Assets.xcassets/AppIcon.appiconset/80.png and /dev/null differ diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/87.png b/Sora/Assets.xcassets/AppIcon.appiconset/87.png deleted file mode 100644 index 0659285..0000000 Binary files a/Sora/Assets.xcassets/AppIcon.appiconset/87.png and /dev/null differ diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sora/Assets.xcassets/AppIcon.appiconset/Contents.json index 1cd4ae9..c7a15f9 100644 --- a/Sora/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/Sora/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,111 +1,33 @@ { "images" : [ { - "filename" : "40-2.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "20x20" + "filename" : "lightmode.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" }, { - "filename" : "60.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "20x20" + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "darkmode.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" }, { - "filename" : "58.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "29x29" - }, - { - "filename" : "87.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "29x29" - }, - { - "filename" : "80.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "40x40" - }, - { - "filename" : "120-1.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "40x40" - }, - { - "filename" : "120.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "60x60" - }, - { - "filename" : "180.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "60x60" - }, - { - "filename" : "20.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "20x20" - }, - { - "filename" : "40-1.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "20x20" - }, - { - "filename" : "29.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "29x29" - }, - { - "filename" : "58-1.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "29x29" - }, - { - "filename" : "40.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "40x40" - }, - { - "filename" : "80-1.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "40x40" - }, - { - "filename" : "76.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "76x76" - }, - { - "filename" : "152.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "76x76" - }, - { - "filename" : "167.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "83.5x83.5" - }, - { - "filename" : "1024.png", - "idiom" : "ios-marketing", - "scale" : "1x", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "filename" : "tinting.png", + "idiom" : "universal", + "platform" : "ios", "size" : "1024x1024" } ], diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/darkmode.png b/Sora/Assets.xcassets/AppIcon.appiconset/darkmode.png new file mode 100644 index 0000000..dc6328e Binary files /dev/null and b/Sora/Assets.xcassets/AppIcon.appiconset/darkmode.png differ diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/lightmode.png b/Sora/Assets.xcassets/AppIcon.appiconset/lightmode.png new file mode 100644 index 0000000..272905b Binary files /dev/null and b/Sora/Assets.xcassets/AppIcon.appiconset/lightmode.png differ diff --git a/Sora/Assets.xcassets/AppIcon.appiconset/tinting.png b/Sora/Assets.xcassets/AppIcon.appiconset/tinting.png new file mode 100644 index 0000000..e9d0104 Binary files /dev/null and b/Sora/Assets.xcassets/AppIcon.appiconset/tinting.png differ diff --git a/Sora/Sora.entitlements b/Sora/Sora.entitlements index ee95ab7..b356c32 100644 --- a/Sora/Sora.entitlements +++ b/Sora/Sora.entitlements @@ -2,6 +2,10 @@ + com.apple.developer.icloud-container-identifiers + + com.apple.developer.ubiquity-kvstore-identifier + $(TeamIdentifierPrefix)$(CFBundleIdentifier) com.apple.security.app-sandbox com.apple.security.network.client diff --git a/Sora/SoraApp.swift b/Sora/SoraApp.swift index 4073e20..795a3d1 100644 --- a/Sora/SoraApp.swift +++ b/Sora/SoraApp.swift @@ -13,6 +13,10 @@ struct SoraApp: App { @StateObject private var moduleManager = ModuleManager() @StateObject private var librarykManager = LibraryManager() + init() { + _ = iCloudSyncManager.shared + } + var body: some Scene { WindowGroup { ContentView() @@ -64,12 +68,25 @@ struct SoraApp: App { return } - AniListToken.exchangeAuthorizationCodeForToken(code: code) { success in - if success { - Logger.shared.log("Token exchange successful") - } else { - Logger.shared.log("Token exchange failed", type: "Error") + switch url.host { + case "anilist": + AniListToken.exchangeAuthorizationCodeForToken(code: code) { success in + if success { + Logger.shared.log("AniList token exchange successful") + } else { + Logger.shared.log("AniList token exchange failed", type: "Error") + } } + case "trakt": + TraktToken.exchangeAuthorizationCodeForToken(code: code) { success in + if success { + Logger.shared.log("Trakt token exchange successful") + } else { + Logger.shared.log("Trakt token exchange failed", type: "Error") + } + } + default: + Logger.shared.log("Unknown authentication service", type: "Error") } } } diff --git a/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift b/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift index ade8898..69622cf 100644 --- a/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift +++ b/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift @@ -88,9 +88,8 @@ class AniListMutation { if let data = data { do { - let responseJSON = try JSONSerialization.jsonObject(with: data, options: []) - print("Successfully updated anime progress") - print(responseJSON) + _ = try JSONSerialization.jsonObject(with: data, options: []) + Logger.shared.log("Successfully updated anime progress", type: "Debug") completion(.success(())) } catch { completion(.failure(error)) diff --git a/Sora/Tracking Services/Trakt/Auth/Trakt-Login.swift b/Sora/Tracking Services/Trakt/Auth/Trakt-Login.swift new file mode 100644 index 0000000..fc0c9c3 --- /dev/null +++ b/Sora/Tracking Services/Trakt/Auth/Trakt-Login.swift @@ -0,0 +1,34 @@ +// +// Trakt-Login.swift +// Sulfur +// +// Created by Francesco on 13/04/25. +// + +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 { + Logger.shared.log("Safari opened successfully", type: "Debug") + } else { + Logger.shared.log("Failed to open Safari", type: "Error") + } + } + } else { + Logger.shared.log("Cannot open URL", type: "Error") + } + } +} diff --git a/Sora/Tracking Services/Trakt/Auth/Trakt-Token.swift b/Sora/Tracking Services/Trakt/Auth/Trakt-Token.swift new file mode 100644 index 0000000..a206631 --- /dev/null +++ b/Sora/Tracking Services/Trakt/Auth/Trakt-Token.swift @@ -0,0 +1,168 @@ +// +// Trakt-Token.swift +// Sulfur +// +// Created by Francesco on 13/04/25. +// + +import UIKit +import Security + +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, + "client_secret": clientSecret, + "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, + "client_secret": clientSecret, + "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 + 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) + } else { + handleFailure(error: "Failed to save tokens to keychain", completion: completion) + } + } else { + let errorMessage = (json["error"] as? String) ?? "Unexpected response" + handleFailure(error: errorMessage, completion: completion) + } + } + } catch { + handleFailure(error: "Failed to parse response: \(error.localizedDescription)", completion: completion) + } + } + } + + 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, + kSecAttrService as String: serviceName, + 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 + } +} + diff --git a/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift b/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift new file mode 100644 index 0000000..2108e9c --- /dev/null +++ b/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift @@ -0,0 +1,131 @@ +// +// TraktPushUpdates.swift +// Sulfur +// +// Created by Francesco on 13/04/25. +// + +import UIKit +import Security + +class TraktMutation { + let apiURL = URL(string: "https://api.trakt.tv")! + + func getTokenFromKeychain() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: TraktToken.serviceName, + kSecAttrAccount as String: TraktToken.accessTokenKey, + 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, + let token = String(data: tokenData, encoding: .utf8) else { + return nil + } + return token + } + + enum ExternalIDType { + case imdb(String) + case tmdb(Int) + + var dictionary: [String: Any] { + switch self { + case .imdb(let id): + return ["imdb": id] + case .tmdb(let id): + return ["tmdb": id] + } + } + } + + func markAsWatched(type: String, externalID: ExternalIDType, episodeNumber: Int? = nil, seasonNumber: Int? = nil, completion: @escaping (Result) -> Void) { + if let sendTraktUpdates = UserDefaults.standard.object(forKey: "sendTraktUpdates") as? Bool, + sendTraktUpdates == false { + 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 = [ + "movies": [ + [ + "ids": externalID.dictionary + ] + ] + ] + + 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": [ + [ + "ids": externalID.dictionary, + "seasons": [ + [ + "number": season, + "episodes": [ + ["number": episode] + ] + ] + ] + ] + ] + ] + + 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 + 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/ContinueWatching/ContinueWatchingManager.swift b/Sora/Utils/ContinueWatching/ContinueWatchingManager.swift index f8b6452..99a7adb 100644 --- a/Sora/Utils/ContinueWatching/ContinueWatchingManager.swift +++ b/Sora/Utils/ContinueWatching/ContinueWatchingManager.swift @@ -11,7 +11,13 @@ class ContinueWatchingManager { static let shared = ContinueWatchingManager() private let storageKey = "continueWatchingItems" - private init() {} + private init() { + NotificationCenter.default.addObserver(self, selector: #selector(handleiCloudSync), name: .iCloudSyncDidComplete, object: nil) + } + + @objc private func handleiCloudSync() { + NotificationCenter.default.post(name: .ContinueWatchingDidUpdate, object: nil) + } func save(item: ContinueWatchingItem) { if item.progress >= 0.9 { diff --git a/Sora/Utils/DownloadManager/DownloadManager.swift b/Sora/Utils/DownloadManager/DownloadManager.swift index bb3d207..78d2b08 100644 --- a/Sora/Utils/DownloadManager/DownloadManager.swift +++ b/Sora/Utils/DownloadManager/DownloadManager.swift @@ -9,10 +9,6 @@ import Foundation import FFmpegSupport import UIKit -extension Notification.Name { - static let DownloadManagerStatusUpdate = Notification.Name("DownloadManagerStatusUpdate") -} - class DownloadManager { static let shared = DownloadManager() diff --git a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift index eda6f2a..18f4d39 100644 --- a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift +++ b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift @@ -75,7 +75,7 @@ extension JSContext { } func setupFetchV2() { - let fetchV2NativeFunction: @convention(block) (String, [String: String]?, String?, String?, JSValue, JSValue) -> Void = { urlString, headers, method, body, 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"]) @@ -104,8 +104,8 @@ extension JSContext { request.setValue(value, forHTTPHeaderField: key) } } - - let task = URLSession.custom.downloadTask(with: request) { tempFileURL, response, error in + 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]) @@ -117,6 +117,12 @@ extension JSContext { reject.call(withArguments: ["No data"]) return } + // initialise return Object + var responseDict: [String: Any] = [ + "status": (response as? HTTPURLResponse)?.statusCode ?? 0, + "headers": (response as? HTTPURLResponse)?.allHeaderFields ?? [:], + "body": "" + ] do { let data = try Data(contentsOf: tempFileURL) @@ -126,12 +132,15 @@ extension JSContext { reject.call(withArguments: ["Response exceeds maximum size"]) return } - + if let text = String(data: data, encoding: .utf8) { - resolve.call(withArguments: [text]) + + responseDict["body"] = text + resolve.call(withArguments: [responseDict]) } else { + // rather than reject -> resolve with empty body as user can utilise reponse headers. Logger.shared.log("Unable to decode data to text", type: "Error") - reject.call(withArguments: ["Unable to decode data"]) + resolve.call(withArguments: [responseDict]) } } catch { @@ -146,35 +155,22 @@ extension JSContext { self.setObject(fetchV2NativeFunction, forKeyedSubscript: "fetchV2Native" as NSString) let fetchv2Definition = """ - function fetchv2(url, headers = {}, method = "GET", body = null) { - if (method === "GET") { - return new Promise(function(resolve, reject) { - fetchV2Native(url, headers, method, null, function(rawText) { // Pass `null` explicitly - const responseObj = { - _data: rawText, - text: function() { - return Promise.resolve(this._data); - }, - json: function() { - try { - return Promise.resolve(JSON.parse(this._data)); - } catch (e) { - return Promise.reject("JSON parse error: " + e.message); - } - } - }; - resolve(responseObj); - }, reject); - }); - } - + function fetchv2(url, headers = {}, method = "GET", body = null, redirect = true ) { + + + var processedBody = null; + if(method != "GET") + { // Ensure body is properly serialized - const processedBody = body ? JSON.stringify(body) : null; + processedBody = body ? JSON.stringify(body) : null + } return new Promise(function(resolve, reject) { - fetchV2Native(url, headers, method, processedBody, function(rawText) { + fetchV2Native(url, headers, method, processedBody, redirect, function(rawText) { const responseObj = { - _data: rawText, + headers: rawText.headers, + status: rawText.status, + _data: rawText.body, text: function() { return Promise.resolve(this._data); }, diff --git a/Sora/Utils/Extensions/Notification+Name.swift b/Sora/Utils/Extensions/Notification+Name.swift new file mode 100644 index 0000000..2cfb5e5 --- /dev/null +++ b/Sora/Utils/Extensions/Notification+Name.swift @@ -0,0 +1,14 @@ +// +// Notification+Name.swift +// Sulfur +// +// Created by Francesco on 17/04/25. +// + +import Foundation + +extension Notification.Name { + static let iCloudSyncDidComplete = Notification.Name("iCloudSyncDidComplete") + static let ContinueWatchingDidUpdate = Notification.Name("ContinueWatchingDidUpdate") + static let DownloadManagerStatusUpdate = Notification.Name("DownloadManagerStatusUpdate") +} diff --git a/Sora/Utils/Extensions/UIDevice+Model.swift b/Sora/Utils/Extensions/UIDevice+Model.swift index 0230166..e07970d 100644 --- a/Sora/Utils/Extensions/UIDevice+Model.swift +++ b/Sora/Utils/Extensions/UIDevice+Model.swift @@ -21,112 +21,206 @@ public extension UIDevice { func mapToDevice(identifier: String) -> String { // swiftlint:disable:this cyclomatic_complexity #if os(iOS) switch identifier { - case "iPod5,1": return "iPod touch (5th generation)" - case "iPod7,1": return "iPod touch (6th generation)" - case "iPod9,1": return "iPod touch (7th generation)" - case "iPhone3,1", "iPhone3,2", "iPhone3,3": return "iPhone 4" - case "iPhone4,1": return "iPhone 4s" - case "iPhone5,1", "iPhone5,2": return "iPhone 5" - case "iPhone5,3", "iPhone5,4": return "iPhone 5c" - case "iPhone6,1", "iPhone6,2": return "iPhone 5s" - case "iPhone7,2": return "iPhone 6" - case "iPhone7,1": return "iPhone 6 Plus" - case "iPhone8,1": return "iPhone 6s" - case "iPhone8,2": return "iPhone 6s Plus" - case "iPhone9,1", "iPhone9,3": return "iPhone 7" - case "iPhone9,2", "iPhone9,4": return "iPhone 7 Plus" - case "iPhone10,1", "iPhone10,4": return "iPhone 8" - case "iPhone10,2", "iPhone10,5": return "iPhone 8 Plus" - case "iPhone10,3", "iPhone10,6": return "iPhone X" - case "iPhone11,2": return "iPhone XS" - case "iPhone11,4", "iPhone11,6": return "iPhone XS Max" - case "iPhone11,8": return "iPhone XR" - case "iPhone12,1": return "iPhone 11" - case "iPhone12,3": return "iPhone 11 Pro" - case "iPhone12,5": return "iPhone 11 Pro Max" - case "iPhone13,1": return "iPhone 12 mini" - case "iPhone13,2": return "iPhone 12" - case "iPhone13,3": return "iPhone 12 Pro" - case "iPhone13,4": return "iPhone 12 Pro Max" - case "iPhone14,4": return "iPhone 13 mini" - case "iPhone14,5": return "iPhone 13" - case "iPhone14,2": return "iPhone 13 Pro" - case "iPhone14,3": return "iPhone 13 Pro Max" - case "iPhone14,7": return "iPhone 14" - case "iPhone14,8": return "iPhone 14 Plus" - case "iPhone15,2": return "iPhone 14 Pro" - case "iPhone15,3": return "iPhone 14 Pro Max" - case "iPhone15,4": return "iPhone 15" - case "iPhone15,5": return "iPhone 15 Plus" - case "iPhone16,1": return "iPhone 15 Pro" - case "iPhone16,2": return "iPhone 15 Pro Max" - case "iPhone17,3": return "iPhone 16" - case "iPhone17,4": return "iPhone 16 Plus" - case "iPhone17,1": return "iPhone 16 Pro" - case "iPhone17,2": return "iPhone 16 Pro Max" - case "iPhone8,4": return "iPhone SE" - case "iPhone12,8": return "iPhone SE (2nd generation)" - case "iPhone14,6": return "iPhone SE (3rd generation)" - case "iPad2,1", "iPad2,2", "iPad2,3", "iPad2,4": return "iPad 2" - case "iPad3,1", "iPad3,2", "iPad3,3": return "iPad (3rd generation)" - case "iPad3,4", "iPad3,5", "iPad3,6": return "iPad (4th generation)" - case "iPad6,11", "iPad6,12": return "iPad (5th generation)" - case "iPad7,5", "iPad7,6": return "iPad (6th generation)" - case "iPad7,11", "iPad7,12": return "iPad (7th generation)" - case "iPad11,6", "iPad11,7": return "iPad (8th generation)" - case "iPad12,1", "iPad12,2": return "iPad (9th generation)" - case "iPad13,18", "iPad13,19": return "iPad (10th generation)" - case "iPad4,1", "iPad4,2", "iPad4,3": return "iPad Air" - case "iPad5,3", "iPad5,4": return "iPad Air 2" - case "iPad11,3", "iPad11,4": return "iPad Air (3rd generation)" - case "iPad13,1", "iPad13,2": return "iPad Air (4th generation)" - case "iPad13,16", "iPad13,17": return "iPad Air (5th generation)" - case "iPad14,8", "iPad14,9": return "iPad Air (11-inch) (M2)" - case "iPad14,10", "iPad14,11": return "iPad Air (13-inch) (M2)" - case "iPad2,5", "iPad2,6", "iPad2,7": return "iPad mini" - case "iPad4,4", "iPad4,5", "iPad4,6": return "iPad mini 2" - case "iPad4,7", "iPad4,8", "iPad4,9": return "iPad mini 3" - case "iPad5,1", "iPad5,2": return "iPad mini 4" - case "iPad11,1", "iPad11,2": return "iPad mini (5th generation)" - case "iPad14,1", "iPad14,2": return "iPad mini (6th generation)" - case "iPad16,1", "iPad16,2": return "iPad mini (A17 Pro)" - case "iPad6,3", "iPad6,4": return "iPad Pro (9.7-inch)" - case "iPad7,3", "iPad7,4": return "iPad Pro (10.5-inch)" - case "iPad8,1", "iPad8,2", "iPad8,3", "iPad8,4": return "iPad Pro (11-inch) (1st generation)" - case "iPad8,9", "iPad8,10": return "iPad Pro (11-inch) (2nd generation)" - case "iPad13,4", "iPad13,5", "iPad13,6", "iPad13,7": return "iPad Pro (11-inch) (3rd generation)" - case "iPad14,3", "iPad14,4": return "iPad Pro (11-inch) (4th generation)" - case "iPad16,3", "iPad16,4": return "iPad Pro (11-inch) (M4)" - case "iPad6,7", "iPad6,8": return "iPad Pro (12.9-inch) (1st generation)" - case "iPad7,1", "iPad7,2": return "iPad Pro (12.9-inch) (2nd generation)" - case "iPad8,5", "iPad8,6", "iPad8,7", "iPad8,8": return "iPad Pro (12.9-inch) (3rd generation)" - case "iPad8,11", "iPad8,12": return "iPad Pro (12.9-inch) (4th generation)" - case "iPad13,8", "iPad13,9", "iPad13,10", "iPad13,11":return "iPad Pro (12.9-inch) (5th generation)" - case "iPad14,5", "iPad14,6": return "iPad Pro (12.9-inch) (6th generation)" - case "iPad16,5", "iPad16,6": return "iPad Pro (13-inch) (M4)" - case "AppleTV5,3": return "Apple TV" - case "AppleTV6,2": return "Apple TV 4K" - case "AudioAccessory1,1": return "HomePod" - case "AudioAccessory5,1": return "HomePod mini" - case "i386", "x86_64", "arm64": return "Simulator \(mapToDevice(identifier: ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "iOS"))" - default: return identifier + case "iPod5,1": + return "iPod touch (5th generation)" + case "iPod7,1": + return "iPod touch (6th generation)" + case "iPod9,1": + return "iPod touch (7th generation)" + case "iPhone3,1", "iPhone3,2", "iPhone3,3": + return "iPhone 4" + case "iPhone4,1": + return "iPhone 4s" + case "iPhone5,1", "iPhone5,2": + return "iPhone 5" + case "iPhone5,3", "iPhone5,4": + return "iPhone 5c" + case "iPhone6,1", "iPhone6,2": + return "iPhone 5s" + case "iPhone7,2": + return "iPhone 6" + case "iPhone7,1": + return "iPhone 6 Plus" + case "iPhone8,1": + return "iPhone 6s" + case "iPhone8,2": + return "iPhone 6s Plus" + case "iPhone9,1", "iPhone9,3": + return "iPhone 7" + case "iPhone9,2", "iPhone9,4": + return "iPhone 7 Plus" + case "iPhone10,1", "iPhone10,4": + return "iPhone 8" + case "iPhone10,2", "iPhone10,5": + return "iPhone 8 Plus" + case "iPhone10,3", "iPhone10,6": + return "iPhone X" + case "iPhone11,2": + return "iPhone XS" + case "iPhone11,4", "iPhone11,6": + return "iPhone XS Max" + case "iPhone11,8": + return "iPhone XR" + case "iPhone12,1": + return "iPhone 11" + case "iPhone12,3": + return "iPhone 11 Pro" + case "iPhone12,5": + return "iPhone 11 Pro Max" + case "iPhone13,1": + return "iPhone 12 mini" + case "iPhone13,2": + return "iPhone 12" + case "iPhone13,3": + return "iPhone 12 Pro" + case "iPhone13,4": + return "iPhone 12 Pro Max" + case "iPhone14,4": + return "iPhone 13 mini" + case "iPhone14,5": + return "iPhone 13" + case "iPhone14,2": + return "iPhone 13 Pro" + case "iPhone14,3": + return "iPhone 13 Pro Max" + case "iPhone14,7": + return "iPhone 14" + case "iPhone14,8": + return "iPhone 14 Plus" + case "iPhone15,2": + return "iPhone 14 Pro" + case "iPhone15,3": + return "iPhone 14 Pro Max" + case "iPhone15,4": + return "iPhone 15" + case "iPhone15,5": + return "iPhone 15 Plus" + case "iPhone16,1": + return "iPhone 15 Pro" + case "iPhone16,2": + return "iPhone 15 Pro Max" + case "iPhone17,3": + return "iPhone 16" + case "iPhone17,4": + return "iPhone 16 Plus" + case "iPhone17,1": + return "iPhone 16 Pro" + case "iPhone17,2": + return "iPhone 16 Pro Max" + case "iPhone8,4": + return "iPhone SE" + case "iPhone12,8": + return "iPhone SE (2nd generation)" + case "iPhone14,6": + return "iPhone SE (3rd generation)" + case "iPad2,1", "iPad2,2", "iPad2,3", "iPad2,4": + return "iPad 2" + case "iPad3,1", "iPad3,2", "iPad3,3": + return "iPad (3rd generation)" + case "iPad3,4", "iPad3,5", "iPad3,6": + return "iPad (4th generation)" + case "iPad6,11", "iPad6,12": + return "iPad (5th generation)" + case "iPad7,5", "iPad7,6": + return "iPad (6th generation)" + case "iPad7,11", "iPad7,12": + return "iPad (7th generation)" + case "iPad11,6", "iPad11,7": + return "iPad (8th generation)" + case "iPad12,1", "iPad12,2": + return "iPad (9th generation)" + case "iPad13,18", "iPad13,19": + return "iPad (10th generation)" + case "iPad4,1", "iPad4,2", "iPad4,3": + return "iPad Air" + case "iPad5,3", "iPad5,4": + return "iPad Air 2" + case "iPad11,3", "iPad11,4": + return "iPad Air (3rd generation)" + case "iPad13,1", "iPad13,2": + return "iPad Air (4th generation)" + case "iPad13,16", "iPad13,17": + return "iPad Air (5th generation)" + case "iPad14,8", "iPad14,9": + return "iPad Air (11-inch) (M2)" + case "iPad14,10", "iPad14,11": + return "iPad Air (13-inch) (M2)" + case "iPad2,5", "iPad2,6", "iPad2,7": + return "iPad mini" + case "iPad4,4", "iPad4,5", "iPad4,6": + return "iPad mini 2" + case "iPad4,7", "iPad4,8", "iPad4,9": + return "iPad mini 3" + case "iPad5,1", "iPad5,2": + return "iPad mini 4" + case "iPad11,1", "iPad11,2": + return "iPad mini (5th generation)" + case "iPad14,1", "iPad14,2": + return "iPad mini (6th generation)" + case "iPad16,1", "iPad16,2": + return "iPad mini (A17 Pro)" + case "iPad6,3", "iPad6,4": + return "iPad Pro (9.7-inch)" + case "iPad7,3", "iPad7,4": + return "iPad Pro (10.5-inch)" + case "iPad8,1", "iPad8,2", "iPad8,3", "iPad8,4": + return "iPad Pro (11-inch) (1st generation)" + case "iPad8,9", "iPad8,10": + return "iPad Pro (11-inch) (2nd generation)" + case "iPad13,4", "iPad13,5", "iPad13,6", "iPad13,7": + return "iPad Pro (11-inch) (3rd generation)" + case "iPad14,3", "iPad14,4": + return "iPad Pro (11-inch) (4th generation)" + case "iPad16,3", "iPad16,4": + return "iPad Pro (11-inch) (M4)" + case "iPad6,7", "iPad6,8": + return "iPad Pro (12.9-inch) (1st generation)" + case "iPad7,1", "iPad7,2": + return "iPad Pro (12.9-inch) (2nd generation)" + case "iPad8,5", "iPad8,6", "iPad8,7", "iPad8,8": + return "iPad Pro (12.9-inch) (3rd generation)" + case "iPad8,11", "iPad8,12": + return "iPad Pro (12.9-inch) (4th generation)" + case "iPad13,8", "iPad13,9", "iPad13,10", "iPad13,11": + return "iPad Pro (12.9-inch) (5th generation)" + case "iPad14,5", "iPad14,6": + return "iPad Pro (12.9-inch) (6th generation)" + case "iPad16,5", "iPad16,6": + return "iPad Pro (13-inch) (M4)" + case "AppleTV5,3": + return "Apple TV" + case "AppleTV6,2": + return "Apple TV 4K" + case "AudioAccessory1,1": + return "HomePod" + case "AudioAccessory5,1": + return "HomePod mini" + case "i386", "x86_64", "arm64": + return "Simulator \(mapToDevice(identifier: ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "iOS"))" + default: + return identifier } #elseif os(tvOS) switch identifier { - case "AppleTV5,3": return "Apple TV 4" - case "AppleTV6,2", "AppleTV11,1", "AppleTV14,1": return "Apple TV 4K" - case "i386", "x86_64": return "Simulator \(mapToDevice(identifier: ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "tvOS"))" - default: return identifier + case "AppleTV5,3": + return "Apple TV 4" + case "AppleTV6,2", "AppleTV11,1", "AppleTV14,1": + return "Apple TV 4K" + case "i386", "x86_64": + return "Simulator \(mapToDevice(identifier: ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "tvOS"))" + default: + return identifier } #elseif os(visionOS) switch identifier { - case "RealityDevice14,1": return "Apple Vision Pro" - default: return identifier + case "RealityDevice14,1": + return "Apple Vision Pro" + default: + return identifier } #endif } return mapToDevice(identifier: identifier) }() - } diff --git a/Sora/Utils/Extensions/URLSession.swift b/Sora/Utils/Extensions/URLSession.swift index 544b084..efa4fcb 100644 --- a/Sora/Utils/Extensions/URLSession.swift +++ b/Sora/Utils/Extensions/URLSession.swift @@ -6,7 +6,27 @@ // import Foundation - +// URL DELEGATE CLASS FOR FETCH API +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) + { + completionHandler(request) // Allow Redirect + } + else + { + completionHandler(nil) // Block Redirect + } + + } + +} extension URLSession { static let userAgents = [ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", @@ -43,4 +63,13 @@ extension URLSession { 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) + let configuration = URLSessionConfiguration.default + configuration.httpAdditionalHeaders = ["User-Agent": randomUserAgent] + return URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil) + } } + diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Components/Double+Extension.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Components/Double+Extension.swift index dd7d7bd..8173515 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/Components/Double+Extension.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/Components/Double+Extension.swift @@ -8,6 +8,7 @@ // import Foundation +import Combine extension Double { func asTimeString(style: DateComponentsFormatter.UnitsStyle) -> String { @@ -37,4 +38,9 @@ extension BinaryFloatingPoint { enum TimeStringStyle { case positional case standard -} \ No newline at end of file +} + +class VolumeViewModel: ObservableObject { + @Published var value: Double = 0.0 +} + diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift index 6a4b6d4..8841dcc 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift @@ -11,14 +11,12 @@ import SwiftUI struct MusicProgressSlider: View { @Binding var value: T - @Binding var bufferValue: T // NEW let inRange: ClosedRange - let activeFillColor: Color let fillColor: Color + let textColor: Color let emptyColor: Color let height: CGFloat - let onEditingChanged: (Bool) -> Void @State private var localRealProgress: T = 0 @@ -28,32 +26,10 @@ struct MusicProgressSlider: View { var body: some View { GeometryReader { bounds in ZStack { - VStack { - // Base track + buffer indicator + current progress + VStack (spacing: 8) { ZStack(alignment: .center) { - - // Entire background track Capsule() .fill(emptyColor) - - // 1) The buffer fill portion (behind the actual progress) - Capsule() // NEW - .fill(fillColor.opacity(0.3)) // or any "bufferColor" - .mask({ - HStack { - Rectangle() - .frame( - width: max( - bounds.size.width * CGFloat(getPrgPercentage(bufferValue)), - 0 - ), - alignment: .leading - ) - Spacer(minLength: 0) - } - }) - - // 2) The actual playback progress Capsule() .fill(isActive ? activeFillColor : fillColor) .mask({ @@ -71,7 +47,6 @@ struct MusicProgressSlider: View { }) } - // Time labels HStack { let shouldShowHours = inRange.upperBound >= 3600 Text(value.asTimeString(style: .positional, showHours: shouldShowHours)) @@ -79,11 +54,10 @@ struct MusicProgressSlider: View { Text("-" + (inRange.upperBound - value) .asTimeString(style: .positional, showHours: shouldShowHours)) } - .font(.system(size: 12)) - .foregroundColor(isActive ? fillColor : emptyColor) + .font(.system(size: 12.5)) + .foregroundColor(textColor) } - .frame(width: isActive ? bounds.size.width * 1.04 : bounds.size.width, - alignment: .center) + .frame(width: isActive ? bounds.size.width * 1.04 : bounds.size.width, alignment: .center) .animation(animation, value: isActive) } .frame(width: bounds.size.width, height: bounds.size.height, alignment: .center) @@ -95,15 +69,15 @@ struct MusicProgressSlider: View { } .onChanged { gesture in localTempProgress = T(gesture.translation.width / bounds.size.width) - value = clampValue(getPrgValue()) + value = max(min(getPrgValue(), inRange.upperBound), inRange.lowerBound) } .onEnded { _ in - localRealProgress = getPrgPercentage(value) + localRealProgress = max(min(localRealProgress + localTempProgress, 1), 0) localTempProgress = 0 } ) .onChange(of: isActive) { newValue in - value = clampValue(getPrgValue()) + value = max(min(getPrgValue(), inRange.upperBound), inRange.lowerBound) onEditingChanged(newValue) } .onAppear { @@ -117,26 +91,23 @@ struct MusicProgressSlider: View { } .frame(height: isActive ? height * 1.25 : height, alignment: .center) } - + private var animation: Animation { - isActive - ? .spring() - : .spring(response: 0.5, dampingFraction: 0.5, blendDuration: 0.6) + if isActive { + return .spring() + } else { + return .spring(response: 0.5, dampingFraction: 0.5, blendDuration: 0.6) + } } - - private func clampValue(_ val: T) -> T { - max(min(val, inRange.upperBound), inRange.lowerBound) - } - - private func getPrgPercentage(_ val: T) -> T { - let clampedValue = clampValue(val) + + private func getPrgPercentage(_ value: T) -> T { let range = inRange.upperBound - inRange.lowerBound - let pct = (clampedValue - inRange.lowerBound) / range - return max(min(pct, 1), 0) + let correctedStartValue = value - inRange.lowerBound + let percentage = correctedStartValue / range + return percentage } private func getPrgValue() -> T { - ((localRealProgress + localTempProgress) * (inRange.upperBound - inRange.lowerBound)) - + inRange.lowerBound + return ((localRealProgress + localTempProgress) * (inRange.upperBound - inRange.lowerBound)) + inRange.lowerBound } } diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Components/VerticalBrightnessSlider.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Components/VerticalBrightnessSlider.swift deleted file mode 100644 index 1da58ea..0000000 --- a/Sora/Utils/MediaPlayer/CustomPlayer/Components/VerticalBrightnessSlider.swift +++ /dev/null @@ -1,136 +0,0 @@ -// -// VerticalBrightnessSlider.swift -// Custom Brighness bar -// -// Created by Pratik on 08/01/23. -// Modified to update screen brightness when used as a brightness slider. -// - -import SwiftUI - -struct VerticalBrightnessSlider: View { - @Binding var value: T - let inRange: ClosedRange - let activeFillColor: Color - let fillColor: Color - let emptyColor: Color - let width: CGFloat - let onEditingChanged: (Bool) -> Void - - // private variables - @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 { - GeometryReader { geo in - ZStack(alignment: .bottom) { - RoundedRectangle(cornerRadius: isActive ? width : width/2, style: .continuous) - .fill(emptyColor) - RoundedRectangle(cornerRadius: isActive ? width : width/2, style: .continuous) - .fill(isActive ? activeFillColor : fillColor) - .mask({ - VStack { - Spacer(minLength: 0) - Rectangle() - .frame(height: max(geo.size.height * CGFloat((localRealProgress + localTempProgress)), 0), - alignment: .leading) - } - }) - - Image(systemName: getIconName) - .font(.system(size: isActive ? 16 : 12, weight: .medium, design: .rounded)) - .foregroundColor(isActive ? fillColor : Color.white) - .animation(.spring(), value: isActive) - .frame(maxHeight: .infinity, alignment: .bottom) - .padding(.bottom) - .overlay { - Image(systemName: getIconName) - .font(.system(size: isActive ? 16 : 12, weight: .medium, design: .rounded)) - .foregroundColor(isActive ? Color.gray : Color.white.opacity(0.8)) - .animation(.spring(), value: isActive) - .frame(maxHeight: .infinity, alignment: .bottom) - .padding(.bottom) - .mask { - VStack { - Spacer(minLength: 0) - Rectangle() - .frame(height: max(geo.size.height * CGFloat((localRealProgress + localTempProgress)), 0), - alignment: .leading) - } - } - } - //.frame(maxWidth: isActive ? .infinity : 0) - // .opacity(isActive ? 1 : 0) - } - .clipped() - } - .frame(height: isActive ? bounds.size.height * 1.15 : bounds.size.height, alignment: .center) - // .shadow(color: .black.opacity(0.1), radius: isActive ? 20 : 0, x: 0, y: 0) - .animation(animation, value: isActive) - } - .frame(width: bounds.size.width, height: bounds.size.height, alignment: .center) - .gesture(DragGesture(minimumDistance: 0, coordinateSpace: .local) - .updating($isActive) { value, state, transaction in - state = true - } - .onChanged { gesture in - localTempProgress = T(-gesture.translation.height / bounds.size.height) - value = max(min(getPrgValue(), inRange.upperBound), inRange.lowerBound) - } - .onEnded { _ in - localRealProgress = max(min(localRealProgress + localTempProgress, 1), 0) - localTempProgress = 0 - } - ) - .onChange(of: isActive) { newValue in - value = max(min(getPrgValue(), inRange.upperBound), inRange.lowerBound) - onEditingChanged(newValue) - } - .onAppear { - localRealProgress = getPrgPercentage(value) - } - .onChange(of: value) { newValue in - if !isActive { - localRealProgress = getPrgPercentage(newValue) - } - } - } - .frame(width: isActive ? width * 1.9 : width, alignment: .center) - .offset(x: isActive ? -10 : 0) - .onChange(of: value) { newValue in - UIScreen.main.brightness = CGFloat(newValue) - } - } - - private var getIconName: String { - let brightnessLevel = CGFloat(localRealProgress + localTempProgress) - switch brightnessLevel { - case ..<0.2: - return "moon.fill" - case 0.2..<0.38: - return "sun.min" - case 0.38..<0.7: - return "sun.max" - default: - return "sun.max.fill" - } - } - - private var animation: Animation { - return .spring() - } - - private func getPrgPercentage(_ value: T) -> T { - let range = inRange.upperBound - inRange.lowerBound - let correctedStartValue = value - inRange.lowerBound - 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 new file mode 100644 index 0000000..15437c6 --- /dev/null +++ b/Sora/Utils/MediaPlayer/CustomPlayer/Components/VolumeSlider.swift @@ -0,0 +1,153 @@ +// +// VolumeSlider.swift +// Custom Seekbar +// +// Created by Pratik on 08/01/23. +// Credits to Pratik https://github.com/pratikg29/Custom-Slider-Control/blob/main/AppleMusicSlider/AppleMusicSlider/VolumeSlider.swift +// + +import SwiftUI + +struct VolumeSlider: View { + @Binding var value: T + let inRange: ClosedRange + let activeFillColor: Color + let fillColor: Color + let emptyColor: Color + let height: CGFloat + let onEditingChanged: (Bool) -> Void + + @State private var localRealProgress: T = 0 + @State private var localTempProgress: T = 0 + @State private var lastVolumeValue: T = 0 + @GestureState private var isActive: Bool = false + + var body: some View { + GeometryReader { bounds in + ZStack { + HStack { + GeometryReader { geo in + ZStack(alignment: .center) { + Capsule().fill(emptyColor) + Capsule().fill(isActive ? activeFillColor : fillColor) + .mask { + HStack { + Rectangle() + .frame( + width: max(geo.size.width * CGFloat(localRealProgress + localTempProgress), 0), + alignment: .leading + ) + Spacer(minLength: 0) + } + } + } + } + + Image(systemName: getIconName) + .font(.system(size: 20, weight: .bold, design: .rounded)) + .frame(width: 30) + .foregroundColor(isActive ? activeFillColor : fillColor) + .onTapGesture { + handleIconTap() + } + } + .frame(width: isActive ? bounds.size.width * 1.02 : bounds.size.width, alignment: .center) + .animation(animation, value: isActive) + } + .frame(width: bounds.size.width, height: bounds.size.height) + .gesture( + DragGesture(minimumDistance: 0, coordinateSpace: .local) + .updating($isActive) { _, state, _ in state = true } + .onChanged { gesture in + let delta = gesture.translation.width / bounds.size.width + localTempProgress = T(delta) + value = sliderValueInRange() + } + .onEnded { _ in + localRealProgress = max(min(localRealProgress + localTempProgress, 1), 0) + localTempProgress = 0 + } + ) + .onChange(of: isActive) { newValue in + if !newValue { + value = sliderValueInRange() + } + onEditingChanged(newValue) + } + .onAppear { + localRealProgress = progress(for: value) + if value > 0 { + lastVolumeValue = value + } + } + .onChange(of: value) { newVal in + if !isActive { + withAnimation(.easeInOut(duration: 0.3)) { + localRealProgress = progress(for: newVal) + } + if newVal > 0 { + lastVolumeValue = newVal + } + } + } + } + .frame(height: isActive ? height * 1.25 : height) + } + + private var getIconName: String { + let p = max(0, min(localRealProgress + localTempProgress, 1)) + let muteThreshold: T = 0 + let lowThreshold: T = 0.2 + let midThreshold: T = 0.35 + let highThreshold: T = 0.7 + + switch p { + case muteThreshold: + return "speaker.slash.fill" + case muteThreshold.. T { + let totalRange = inRange.upperBound - inRange.lowerBound + let adjustedVal = val - inRange.lowerBound + return adjustedVal / totalRange + } + + private func sliderValueInRange() -> T { + let totalProgress = localRealProgress + localTempProgress + let rawVal = totalProgress * (inRange.upperBound - inRange.lowerBound) + + inRange.lowerBound + return max(min(rawVal, inRange.upperBound), inRange.lowerBound) + } +} diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index fae44fd..929b00b 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -10,17 +10,17 @@ import MarqueeLabel import AVKit import SwiftUI import AVFoundation +import MediaPlayer // MARK: - SliderViewModel class SliderViewModel: ObservableObject { @Published var sliderValue: Double = 0.0 - @Published var bufferValue: Double = 0.0 } // MARK: - CustomMediaPlayerViewController -class CustomMediaPlayerViewController: UIViewController { +class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDelegate { let module: ScrapingModule let streamURL: String let fullUrl: String @@ -31,6 +31,11 @@ class CustomMediaPlayerViewController: UIViewController { 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? @@ -43,29 +48,31 @@ class CustomMediaPlayerViewController: UIViewController { var duration: Double = 0.0 var isVideoLoaded = false - var brightnessValue: Double = Double(UIScreen.main.brightness) - var brightnessSliderHostingController: UIHostingController>? - 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") } - var showWatchNextButton = true - var watchNextButtonTimer: Timer? - var isWatchNextRepositioned: Bool = false - var isWatchNextVisible: Bool = false - var lastDuration: Double = 0.0 - var watchNextButtonAppearedAt: Double? + 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 @@ -88,6 +95,7 @@ class CustomMediaPlayerViewController: UIViewController { var dismissButton: UIButton! var menuButton: UIButton! var watchNextButton: UIButton! + var watchNextIconButton: UIButton! var blackCoverView: UIView! var speedButton: UIButton! var skip85Button: UIButton! @@ -117,6 +125,14 @@ class CustomMediaPlayerViewController: UIViewController { private var loadedTimeRangesObservation: NSKeyValueObservation? private var playerTimeControlStatusObserver: NSKeyValueObservation? + private var volumeObserver: NSKeyValueObservation? + private var audioSession = AVAudioSession.sharedInstance() + private var hiddenVolumeView = MPVolumeView(frame: .zero) + private var systemVolumeSlider: UISlider? + private var volumeValue: Double = 0.0 + private var volumeViewModel = VolumeViewModel() + var volumeSliderHostingView: UIView? + init(module: ScrapingModule, urlString: String, fullUrl: String, @@ -156,9 +172,7 @@ class CustomMediaPlayerViewController: UIViewController { let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(fullUrl)") if lastPlayedTime > 0 { let seekTime = CMTime(seconds: lastPlayedTime, preferredTimescale: 1) - self.player.seek(to: seekTime) { [weak self] _ in - self?.updateBufferValue() - } + self.player.seek(to: seekTime) } } @@ -171,30 +185,24 @@ class CustomMediaPlayerViewController: UIViewController { view.backgroundColor = .black setupHoldGesture() - setInitialPlayerRate() loadSubtitleSettings() setupPlayerViewController() setupControls() - brightnessControl() setupSkipAndDismissGestures() addInvisibleControlOverlays() + setupWatchNextButton() setupSubtitleLabel() setupDismissButton() + volumeSlider() setupSpeedButton() setupQualityButton() setupMenuButton() setupMarqueeLabel() setupSkip85Button() - setupWatchNextButton() addTimeObserver() startUpdateTimer() setupAudioSession() - if let item = player.currentItem { - loadedTimeRangesObservation = item.observe(\.loadedTimeRanges, options: [.new, .initial]) { [weak self] (playerItem, change) in - self?.updateBufferValue() - } - } DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.checkForHLSStream() @@ -204,8 +212,26 @@ class CustomMediaPlayerViewController: UIViewController { holdForPause() } + do { + try audioSession.setActive(true) + } catch { + print("Error activating audio session: \(error)") + } - player.play() + volumeViewModel.value = Double(audioSession.outputVolume) + + volumeObserver = audioSession.observe(\.outputVolume, options: [.new]) { [weak self] session, 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) @@ -218,6 +244,20 @@ class CustomMediaPlayerViewController: UIViewController { self.watchNextButton.alpha = 1.0 self.view.layoutIfNeeded() } + + hiddenVolumeView.showsRouteButton = false + 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) { @@ -227,55 +267,54 @@ class CustomMediaPlayerViewController: UIViewController { }) } - /// In layoutSubviews, check if the text width is larger than the available space and update the label’s properties. override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - // Safely unwrap marqueeLabel guard let marqueeLabel = marqueeLabel else { - return // or handle the error gracefully + return } let availableWidth = marqueeLabel.frame.width let textWidth = marqueeLabel.intrinsicContentSize.width - + if textWidth > availableWidth { marqueeLabel.lineBreakMode = .byTruncatingTail } else { marqueeLabel.lineBreakMode = .byClipping } + 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) - + NotificationCenter.default.addObserver(self, selector: #selector(playerItemDidChange), name: .AVPlayerItemNewAccessLogEntry, object: nil) skip85Button?.isHidden = !isSkip85Visible } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - - loadedTimeRangesObservation?.invalidate() - loadedTimeRangesObservation = nil + 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() - - if let playbackSpeed = player?.rate { - UserDefaults.standard.set(playbackSpeed, forKey: "lastPlaybackSpeed") - } - } override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { @@ -285,27 +324,25 @@ class CustomMediaPlayerViewController: UIViewController { } if keyPath == "loadedTimeRanges" { - updateBufferValue() } } - private func updateBufferValue() { - guard let item = player.currentItem else { return } - - if let timeRange = item.loadedTimeRanges.first?.timeRangeValue { - let buffered = CMTimeGetSeconds(timeRange.start) + CMTimeGetSeconds(timeRange.duration) - DispatchQueue.main.async { - self.sliderViewModel.bufferValue = buffered - } - } - } @objc private func playerItemDidChange() { DispatchQueue.main.async { [weak self] in guard let self = self else { return } if self.qualityButton.isHidden && self.isHLSStream { + // 1) reveal the quality button self.qualityButton.isHidden = false self.qualityButton.menu = self.qualitySelectionMenu() + + // 2) update the trailing constraint for the menuButton + self.updateMenuButtonConstraints() + + // 3) animate the shift + UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) { + self.view.layoutIfNeeded() + } } } } @@ -357,6 +394,12 @@ class CustomMediaPlayerViewController: UIViewController { 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) @@ -373,7 +416,19 @@ class CustomMediaPlayerViewController: UIViewController { 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 @@ -383,6 +438,12 @@ class CustomMediaPlayerViewController: UIViewController { 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) @@ -401,15 +462,12 @@ class CustomMediaPlayerViewController: UIViewController { get: { self.sliderViewModel.sliderValue }, set: { self.sliderViewModel.sliderValue = $0 } ), - bufferValue: Binding( - get: { self.sliderViewModel.bufferValue }, // NEW - set: { self.sliderViewModel.bufferValue = $0 } // NEW - ), inRange: 0...(duration > 0 ? duration : 1.0), activeFillColor: .white, - fillColor: .white.opacity(0.5), + fillColor: .white.opacity(0.6), + textColor: .white.opacity(0.7), emptyColor: .white.opacity(0.3), - height: 30, + height: 33, onEditingChanged: { editing in if editing { self.isSliderEditing = true @@ -423,7 +481,6 @@ class CustomMediaPlayerViewController: UIViewController { let final = self.player.currentTime().seconds self.sliderViewModel.sliderValue = final self.currentTimeVal = final - self.updateBufferValue() self.isSliderEditing = false if wasPlaying { @@ -472,49 +529,6 @@ class CustomMediaPlayerViewController: UIViewController { view.addGestureRecognizer(holdForPauseGesture) } - func brightnessControl() { - let brightnessSlider = VerticalBrightnessSlider( - value: Binding( - get: { self.brightnessValue }, - set: { newValue in self.brightnessValue = newValue } - ), - inRange: 0...1, - activeFillColor: .white, - fillColor: .white.opacity(0.5), - emptyColor: .white.opacity(0.3), - width: 22, - onEditingChanged: { editing in } - ) - - // Create the container for the brightness slider - let brightnessContainer = UIView() - brightnessContainer.translatesAutoresizingMaskIntoConstraints = false - brightnessContainer.backgroundColor = .clear - - controlsContainerView.addSubview(brightnessContainer) - - NSLayoutConstraint.activate([ - brightnessContainer.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 16), - brightnessContainer.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor), - brightnessContainer.widthAnchor.constraint(equalToConstant: 22), - brightnessContainer.heightAnchor.constraint(equalToConstant: 170) - ]) - - brightnessSliderHostingController = UIHostingController(rootView: brightnessSlider) - guard let brightnessSliderView = brightnessSliderHostingController?.view else { return } - brightnessSliderView.backgroundColor = .clear - brightnessSliderView.translatesAutoresizingMaskIntoConstraints = false - - brightnessContainer.addSubview(brightnessSliderView) - - NSLayoutConstraint.activate([ - brightnessSliderView.topAnchor.constraint(equalTo: brightnessContainer.topAnchor), - brightnessSliderView.bottomAnchor.constraint(equalTo: brightnessContainer.bottomAnchor), - brightnessSliderView.leadingAnchor.constraint(equalTo: brightnessContainer.leadingAnchor), - brightnessSliderView.trailingAnchor.constraint(equalTo: brightnessContainer.trailingAnchor) - ]) - } - func addInvisibleControlOverlays() { let playPauseOverlay = UIButton(type: .custom) playPauseOverlay.backgroundColor = .clear @@ -528,16 +542,18 @@ class CustomMediaPlayerViewController: UIViewController { playPauseOverlay.heightAnchor.constraint(equalTo: playPauseButton.heightAnchor, constant: 20) ]) } - + func setupSkipAndDismissGestures() { - let doubleTapGesture = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTap(_:))) - doubleTapGesture.numberOfTapsRequired = 2 - view.addGestureRecognizer(doubleTapGesture) - - if let gestures = view.gestureRecognizers { - for gesture in gestures { - if let tapGesture = gesture as? UITapGestureRecognizer, tapGesture.numberOfTapsRequired == 1 { - tapGesture.require(toFail: doubleTapGesture) + 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 { + tapGesture.require(toFail: doubleTapGesture) + } } } } @@ -548,7 +564,7 @@ class CustomMediaPlayerViewController: UIViewController { func showSkipFeedback(direction: String) { let diameter: CGFloat = 600 - + if let existingFeedback = view.viewWithTag(999) { existingFeedback.layer.removeAllAnimations() existingFeedback.removeFromSuperview() @@ -561,7 +577,7 @@ class CustomMediaPlayerViewController: UIViewController { 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 @@ -647,13 +663,22 @@ class CustomMediaPlayerViewController: UIViewController { } func setupDismissButton() { + let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) + let image = UIImage(systemName: "xmark", withConfiguration: config) + dismissButton = UIButton(type: .system) - dismissButton.setImage(UIImage(systemName: "xmark"), for: .normal) + 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), @@ -669,12 +694,18 @@ class CustomMediaPlayerViewController: UIViewController { marqueeLabel.textColor = .white marqueeLabel.font = UIFont.systemFont(ofSize: 14, weight: .heavy) - marqueeLabel.speed = .rate(30) // Adjust scrolling speed as needed + marqueeLabel.speed = .rate(35) // Adjust scrolling speed as needed marqueeLabel.fadeLength = 10.0 // Fading at the label’s edges marqueeLabel.leadingBuffer = 1.0 // Left inset for scrolling marqueeLabel.trailingBuffer = 16.0 // Right inset for scrolling 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 @@ -683,7 +714,7 @@ class CustomMediaPlayerViewController: UIViewController { // 1. Portrait mode with button visible portraitButtonVisibleConstraints = [ - marqueeLabel.leadingAnchor.constraint(equalTo: dismissButton.trailingAnchor, constant: 12), + marqueeLabel.leadingAnchor.constraint(equalTo: dismissButton.trailingAnchor, constant: 8), marqueeLabel.trailingAnchor.constraint(equalTo: menuButton.leadingAnchor, constant: -16), marqueeLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor) ] @@ -710,40 +741,59 @@ class CustomMediaPlayerViewController: UIViewController { ] updateMarqueeConstraints() } - - func updateMarqueeConstraints() { - // First, remove any existing marquee constraints. - NSLayoutConstraint.deactivate(currentMarqueeConstraints) - - // Decide on spacing constants based on orientation. - let isPortrait = UIDevice.current.orientation.isPortrait || view.bounds.height > view.bounds.width - let leftSpacing: CGFloat = isPortrait ? 2 : 1 - let rightSpacing: CGFloat = isPortrait ? 16 : 8 - - // Determine which button to use for the trailing anchor. - var trailingAnchor: NSLayoutXAxisAnchor = controlsContainerView.trailingAnchor // default fallback - if let menu = menuButton, !menu.isHidden { - trailingAnchor = menu.leadingAnchor - } else if let quality = qualityButton, !quality.isHidden { - trailingAnchor = quality.leadingAnchor - } else if let speed = speedButton, !speed.isHidden { - trailingAnchor = speed.leadingAnchor + + func volumeSlider() { + let container = VolumeSliderContainer(volumeVM: self.volumeViewModel) { newVal in + if let sysSlider = self.systemVolumeSlider { + sysSlider.value = Float(newVal) + } } - // Create new constraints for the marquee label. - currentMarqueeConstraints = [ - marqueeLabel.leadingAnchor.constraint(equalTo: dismissButton.trailingAnchor, constant: leftSpacing), - marqueeLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -rightSpacing), - marqueeLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor) - ] + let hostingController = UIHostingController(rootView: container) + hostingController.view.backgroundColor = UIColor.clear + hostingController.view.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate(currentMarqueeConstraints) - view.layoutIfNeeded() + 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), + hostingController.view.widthAnchor.constraint(equalToConstant: 160), + hostingController.view.heightAnchor.constraint(equalToConstant: 30) + ]) + } + + + func updateMarqueeConstraints() { + UIView.performWithoutAnimation { + NSLayoutConstraint.deactivate(currentMarqueeConstraints) + + let leftSpacing: CGFloat = 2 + let rightSpacing: CGFloat = 6 + let trailingAnchor: NSLayoutXAxisAnchor = (volumeSliderHostingView?.isHidden == false) + ? volumeSliderHostingView!.leadingAnchor + : view.safeAreaLayoutGuide.trailingAnchor + + currentMarqueeConstraints = [ + marqueeLabel.leadingAnchor.constraint(equalTo: dismissButton.trailingAnchor, constant: leftSpacing), + marqueeLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -rightSpacing - 10), + marqueeLabel.centerYAnchor.constraint(equalTo: dismissButton.centerYAnchor) + ] + NSLayoutConstraint.activate(currentMarqueeConstraints) + view.layoutIfNeeded() + } } func setupMenuButton() { + let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) + let image = UIImage(systemName: "text.bubble", withConfiguration: config) + menuButton = UIButton(type: .system) - menuButton.setImage(UIImage(systemName: "text.bubble"), for: .normal) + menuButton.setImage(image, for: .normal) menuButton.tintColor = .white if let subtitlesURL = subtitlesURL, !subtitlesURL.isEmpty { @@ -753,20 +803,30 @@ class CustomMediaPlayerViewController: UIViewController { 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: controlsContainerView.topAnchor, constant: 20), - menuButton.trailingAnchor.constraint(equalTo: qualityButton.leadingAnchor, constant: -20), + 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(UIImage(systemName: "speedometer"), for: .normal) + speedButton.setImage(image, for: .normal) speedButton.tintColor = .white speedButton.showsMenuAsPrimaryAction = true speedButton.menu = speedChangerMenu() @@ -775,62 +835,66 @@ class CustomMediaPlayerViewController: UIViewController { speedButton.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ - speedButton.topAnchor.constraint(equalTo: controlsContainerView.topAnchor, constant: 20), - speedButton.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -20), + speedButton.topAnchor.constraint(equalTo: watchNextButton.topAnchor), + speedButton.trailingAnchor.constraint(equalTo: watchNextButton.leadingAnchor, constant: 18), speedButton.widthAnchor.constraint(equalToConstant: 40), speedButton.heightAnchor.constraint(equalToConstant: 40) ]) } func setupWatchNextButton() { - let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .regular) - let image = UIImage(systemName: "forward.fill", withConfiguration: config) + let config = UIImage.SymbolConfiguration(pointSize: 15, weight: .bold) + let image = UIImage(systemName: "forward.end", withConfiguration: config) watchNextButton = UIButton(type: .system) - watchNextButton.setTitle(" Play Next", for: .normal) - watchNextButton.titleLabel?.font = UIFont.systemFont(ofSize: 14) watchNextButton.setImage(image, for: .normal) - watchNextButton.tintColor = .black - watchNextButton.backgroundColor = .white - watchNextButton.layer.cornerRadius = 25 - watchNextButton.setTitleColor(.black, for: .normal) - watchNextButton.addTarget(self, action: #selector(watchNextTapped), for: .touchUpInside) - watchNextButton.alpha = 0.0 - watchNextButton.isHidden = true + watchNextButton.backgroundColor = .clear + watchNextButton.tintColor = .white + watchNextButton.setTitleColor(.white, for: .normal) - view.addSubview(watchNextButton) + // 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 - watchNextButtonNormalConstraints = [ - watchNextButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), - watchNextButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.centerYAnchor), - watchNextButton.heightAnchor.constraint(equalToConstant: 50), - watchNextButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 120) - ] - - watchNextButtonControlsConstraints = [ - watchNextButton.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor), + NSLayoutConstraint.activate([ + watchNextButton.trailingAnchor.constraint(equalTo: sliderHostingController!.view.trailingAnchor, constant: 20), watchNextButton.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5), - watchNextButton.heightAnchor.constraint(equalToConstant: 47), - watchNextButton.widthAnchor.constraint(greaterThanOrEqualToConstant: 97) - ] - - NSLayoutConstraint.activate(watchNextButtonNormalConstraints) + watchNextButton.heightAnchor.constraint(equalToConstant: 40), + watchNextButton.widthAnchor.constraint(equalToConstant: 80) + ]) } func setupSkip85Button() { - let config = UIImage.SymbolConfiguration(pointSize: 14, weight: .regular) + 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) + skip85Button.titleLabel?.font = UIFont.systemFont(ofSize: 14, weight: .bold) skip85Button.setImage(image, for: .normal) - skip85Button.tintColor = .black - skip85Button.backgroundColor = .white - skip85Button.layer.cornerRadius = 25 - skip85Button.setTitleColor(.black, for: .normal) - skip85Button.alpha = 0.8 + + skip85Button.backgroundColor = UIColor(red: 51/255.0, green: 51/255.0, blue: 51/255.0, alpha: 0.8) + skip85Button.tintColor = .white + skip85Button.setTitleColor(.white, for: .normal) + skip85Button.layer.cornerRadius = 21 + skip85Button.alpha = 0.7 + + skip85Button.contentEdgeInsets = UIEdgeInsets(top: 6, left: 10, bottom: 6, right: 10) + + skip85Button.layer.shadowColor = UIColor.black.cgColor + skip85Button.layer.shadowOffset = CGSize(width: 0, height: 2) + skip85Button.layer.shadowOpacity = 0.6 + skip85Button.layer.shadowRadius = 4 + skip85Button.layer.masksToBounds = false + skip85Button.addTarget(self, action: #selector(skip85Tapped), for: .touchUpInside) view.addSubview(skip85Button) @@ -839,27 +903,37 @@ class CustomMediaPlayerViewController: UIViewController { NSLayoutConstraint.activate([ skip85Button.leadingAnchor.constraint(equalTo: sliderHostingController!.view.leadingAnchor), skip85Button.bottomAnchor.constraint(equalTo: sliderHostingController!.view.topAnchor, constant: -5), - skip85Button.heightAnchor.constraint(equalToConstant: 47), + 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(UIImage(systemName: "4k.tv"), for: .normal) + 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: controlsContainerView.topAnchor, constant: 20), - qualityButton.trailingAnchor.constraint(equalTo: speedButton.leadingAnchor, constant: -20), + qualityButton.topAnchor.constraint(equalTo: speedButton.topAnchor), + qualityButton.trailingAnchor.constraint(equalTo: speedButton.leadingAnchor, constant: -6), qualityButton.widthAnchor.constraint(equalToConstant: 40), qualityButton.heightAnchor.constraint(equalToConstant: 40) ]) @@ -898,7 +972,6 @@ class CustomMediaPlayerViewController: UIViewController { let currentItem = self.player.currentItem, currentItem.duration.seconds.isFinite else { return } - self.updateBufferValue() let currentDuration = currentItem.duration.seconds if currentDuration.isNaN || currentDuration <= 0 { return } @@ -938,18 +1011,15 @@ class CustomMediaPlayerViewController: UIViewController { ContinueWatchingManager.shared.save(item: item) } + let remainingPercentage = (self.duration - self.currentTimeVal) / self.duration - if remainingPercentage < 0.1 && self.module.metadata.type == "anime" && self.aniListID != 0 { - let aniListMutation = AniListMutation() - aniListMutation.updateAnimeProgress(animeId: self.aniListID, episodeNumber: self.episodeNumber) { result in - switch result { - case .success: - Logger.shared.log("Successfully updated AniList progress for episode \(self.episodeNumber)", type: "General") - case .failure(let error): - Logger.shared.log("Failed to update AniList progress: \(error.localizedDescription)", type: "Error") - } - } + if remainingPercentage < 0.1 && + self.aniListID != 0 && + !self.aniListUpdatedSuccessfully && + !self.aniListUpdateImpossible + { + self.tryAniListUpdate() } self.sliderHostingController?.rootView = MusicProgressSlider( @@ -959,88 +1029,26 @@ class CustomMediaPlayerViewController: UIViewController { self.sliderViewModel.sliderValue = max(0, min($0, self.duration)) } ), - bufferValue: Binding(get: { self.sliderViewModel.bufferValue }, - set: { self.sliderViewModel.bufferValue = $0 }), inRange: 0...(self.duration > 0 ? self.duration : 1.0), + inRange: 0...(self.duration > 0 ? self.duration : 1.0), activeFillColor: .white, fillColor: .white.opacity(0.6), + textColor: .white.opacity(0.7), emptyColor: .white.opacity(0.3), - height: 30, + height: 33, onEditingChanged: { editing in if !editing { let targetTime = CMTime( seconds: self.sliderViewModel.sliderValue, preferredTimescale: 600 ) - self.player.seek(to: targetTime) { [weak self] finished in - self?.updateBufferValue() - } + self.player.seek(to: targetTime) } } ) } - - let isNearEnd = (self.duration - self.currentTimeVal) <= (self.duration * 0.10) - && self.currentTimeVal != self.duration - && self.showWatchNextButton - && self.duration != 0 - - if isNearEnd { - if !self.isWatchNextVisible { - self.isWatchNextVisible = true - self.watchNextButtonAppearedAt = self.currentTimeVal - - if self.isControlsVisible { - NSLayoutConstraint.deactivate(self.watchNextButtonNormalConstraints) - NSLayoutConstraint.activate(self.watchNextButtonControlsConstraints) - } else { - NSLayoutConstraint.deactivate(self.watchNextButtonControlsConstraints) - NSLayoutConstraint.activate(self.watchNextButtonNormalConstraints) - } - self.watchNextButton.isHidden = false - self.watchNextButton.alpha = 0.0 - UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseInOut, animations: { - self.watchNextButton.alpha = 0.8 - }, completion: nil) - } - - if let appearedAt = self.watchNextButtonAppearedAt, - (self.currentTimeVal - appearedAt) >= 5, - !self.isWatchNextRepositioned { - UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseInOut, animations: { - self.watchNextButton.alpha = 0.0 - }, completion: { _ in - self.watchNextButton.isHidden = true - NSLayoutConstraint.deactivate(self.watchNextButtonNormalConstraints) - NSLayoutConstraint.activate(self.watchNextButtonControlsConstraints) - self.isWatchNextRepositioned = true - }) - } - } else { - self.watchNextButtonAppearedAt = nil - self.isWatchNextVisible = false - self.isWatchNextRepositioned = false - UIView.animate(withDuration: 0.5, delay: 0, options: .curveEaseInOut, animations: { - self.watchNextButton.alpha = 0.0 - }, completion: { _ in - self.watchNextButton.isHidden = true - }) - } } } - func repositionWatchNextButton() { - self.isWatchNextRepositioned = true - UIView.animate(withDuration: 0.3, animations: { - NSLayoutConstraint.deactivate(self.watchNextButtonNormalConstraints) - NSLayoutConstraint.activate(self.watchNextButtonControlsConstraints) - self.view.layoutIfNeeded() - self.watchNextButton.alpha = 0.0 - }, completion: { _ in - self.watchNextButton.isHidden = true - }) - self.watchNextButtonTimer?.invalidate() - self.watchNextButtonTimer = nil - } func startUpdateTimer() { updateTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in @@ -1049,35 +1057,31 @@ class CustomMediaPlayerViewController: UIViewController { } } + func updateMenuButtonConstraints() { + // tear down last one + currentMenuButtonTrailing.isActive = false + + // pick the “next” visible control + let anchor: NSLayoutXAxisAnchor + if !qualityButton.isHidden { + anchor = qualityButton.leadingAnchor + } else if !speedButton.isHidden { + anchor = speedButton.leadingAnchor + } else { + anchor = controlsContainerView.trailingAnchor + } + + // rebuild & activate + currentMenuButtonTrailing = menuButton.trailingAnchor.constraint(equalTo: anchor, constant: -6) + currentMenuButtonTrailing.isActive = true + } + @objc func toggleControls() { isControlsVisible.toggle() - UIView.animate(withDuration: 0.3, delay: 0, options: .curveEaseInOut, animations: { - self.controlsContainerView.alpha = self.isControlsVisible ? 1 : 0 - self.skip85Button.alpha = self.isControlsVisible ? 0.8 : 0 - - if self.isControlsVisible { - NSLayoutConstraint.deactivate(self.watchNextButtonNormalConstraints) - NSLayoutConstraint.activate(self.watchNextButtonControlsConstraints) - if self.isWatchNextRepositioned || self.isWatchNextVisible { - self.watchNextButton.isHidden = false - UIView.animate(withDuration: 0.3, animations: { - self.watchNextButton.alpha = 0.8 - }) - } - } else { - if !self.isWatchNextRepositioned && self.isWatchNextVisible { - NSLayoutConstraint.deactivate(self.watchNextButtonControlsConstraints) - NSLayoutConstraint.activate(self.watchNextButtonNormalConstraints) - } - if self.isWatchNextRepositioned { - UIView.animate(withDuration: 0.5, animations: { - self.watchNextButton.alpha = 0.0 - }, completion: { _ in - self.watchNextButton.isHidden = true - }) - } - } - self.view.layoutIfNeeded() + UIView.animate(withDuration: 0.2, delay: 0, options: .curveEaseInOut, animations: { + let alphaVal: CGFloat = self.isControlsVisible ? 1 : 0 + self.controlsContainerView.alpha = alphaVal + self.skip85Button.alpha = alphaVal }) } @@ -1087,9 +1091,9 @@ class CustomMediaPlayerViewController: UIViewController { let finalSkip = holdValue > 0 ? holdValue : 30 currentTimeVal = max(currentTimeVal - finalSkip, 0) player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in - guard let self = self else { return } - self.updateBufferValue() + guard self != nil else { return } } + animateButtonRotation(backwardButton, clockwise: false) } } @@ -1099,9 +1103,9 @@ class CustomMediaPlayerViewController: UIViewController { let finalSkip = holdValue > 0 ? holdValue : 30 currentTimeVal = min(currentTimeVal + finalSkip, duration) player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in - guard let self = self else { return } - self.updateBufferValue() + guard self != nil else { return } } + animateButtonRotation(forwardButton) } } @@ -1110,9 +1114,9 @@ class CustomMediaPlayerViewController: UIViewController { let finalSkip = skipValue > 0 ? skipValue : 10 currentTimeVal = max(currentTimeVal - finalSkip, 0) player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in - guard let self = self else { return } - self.updateBufferValue() + guard self != nil else { return } } + animateButtonRotation(backwardButton, clockwise: false) } @objc func seekForward() { @@ -1120,9 +1124,8 @@ class CustomMediaPlayerViewController: UIViewController { let finalSkip = skipValue > 0 ? skipValue : 10 currentTimeVal = min(currentTimeVal + finalSkip, duration) player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in - guard let self = self else { return } - self.updateBufferValue() - } + guard self != nil else { return } } + animateButtonRotation(forwardButton) } @objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) { @@ -1146,15 +1149,19 @@ class CustomMediaPlayerViewController: UIViewController { isPlaying = false playPauseButton.image = UIImage(systemName: "play.fill") - if !isControlsVisible { - isControlsVisible = true - UIView.animate(withDuration: 0.2) { - self.controlsContainerView.alpha = 1.0 - self.skip85Button.alpha = 0.8 - self.view.layoutIfNeeded() + // Defer the UI animation so that it doesn't block the pause call + DispatchQueue.main.async { + if !self.isControlsVisible { + self.isControlsVisible = true + UIView.animate(withDuration: 0.1, animations: { + self.controlsContainerView.alpha = 1.0 + self.skip85Button.alpha = 0.8 + // Removed layoutIfNeeded() to avoid forcing a layout pass here + }) } } } else { + // Play immediately player.play() isPlaying = true playPauseButton.image = UIImage(systemName: "pause.fill") @@ -1198,6 +1205,66 @@ class CustomMediaPlayerViewController: UIViewController { 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() + } + } else { + Logger.shared.log("AniList update reached max retries. No more attempts.", type: "Error") + } + } + } + } + } + + 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") @@ -1209,13 +1276,13 @@ class CustomMediaPlayerViewController: UIViewController { guard let self = self, let data = data, let content = String(data: data, encoding: .utf8) else { - print("Failed to load m3u8 file") - DispatchQueue.main.async { - self?.qualities = [] - completion() - } - return - } + Logger.shared.log("Failed to load m3u8 file") + DispatchQueue.main.async { + self?.qualities = [] + completion() + } + return + } let lines = content.components(separatedBy: .newlines) var qualities: [(String, String)] = [] @@ -1361,19 +1428,23 @@ class CustomMediaPlayerViewController: UIViewController { parseM3U8(url: url) { [weak self] in guard let self = self else { return } - - if let lastSelectedQuality = UserDefaults.standard.string(forKey: "lastSelectedQuality"), - self.qualities.contains(where: { $0.1 == lastSelectedQuality }) { - self.switchToQuality(urlString: lastSelectedQuality) + if let last = UserDefaults.standard.string(forKey: "lastSelectedQuality"), + self.qualities.contains(where: { $0.1 == last }) { + self.switchToQuality(urlString: last) } + // reveal + animate self.qualityButton.isHidden = false self.qualityButton.menu = self.qualitySelectionMenu() - self.updateMarqueeConstraints() + self.updateMenuButtonConstraints() + UIView.animate(withDuration: 0.25, delay: 0, options: .curveEaseInOut) { + self.view.layoutIfNeeded() + } } } else { isHLSStream = false qualityButton.isHidden = true + updateMenuButtonConstraints() } } @@ -1584,6 +1655,17 @@ class CustomMediaPlayerViewController: UIViewController { } catch { Logger.shared.log("Didn't set up AVAudioSession: \(error)", type: "Debug") } + + volumeObserver = audioSession.observe(\.outputVolume, options: [.new]) { [weak self] session, change in + guard let newVol = change.newValue else { return } + if let oldVol = self?.volumeViewModel.value, abs(Double(newVol) - oldVol) < 0.02 { + return + } + DispatchQueue.main.async { + self?.volumeViewModel.value = Double(newVol) + Logger.shared.log("Hardware volume changed, new value: \(newVol)", type: "Debug") + } + } } private func setupHoldGesture() { @@ -1642,15 +1724,38 @@ class CustomMediaPlayerViewController: UIViewController { if player.timeControlStatus == .paused, let reason = player.reasonForWaitingToPlay { Logger.shared.log("Paused reason: \(reason)", type: "Error") - if reason == .toMinimizeStalls || reason == .evaluatingBufferingRate { + if reason == .toMinimizeStalls { player.play() } } } } + + struct VolumeSliderContainer: View { + @ObservedObject var volumeVM: VolumeViewModel + var updateSystemSlider: ((Double) -> Void)? = nil + + var body: some View { + VolumeSlider( + value: Binding( + get: { volumeVM.value }, + set: { newVal in + volumeVM.value = newVal + updateSystemSlider?(newVal) + } + ), + inRange: 0...1, + activeFillColor: .white, + fillColor: .white.opacity(0.6), + emptyColor: .white.opacity(0.3), + height: 10, + onEditingChanged: { _ in } + ) + .shadow(color: Color.black.opacity(0.6), radius: 4, x: 0, y: 2) + } + } } - // yes? Like the plural of the famous american rapper ye? -IBHRAD // low taper fade the meme is massive -cranci // cranci still doesnt have a job -seiike diff --git a/Sora/Utils/MediaPlayer/VideoPlayer.swift b/Sora/Utils/MediaPlayer/VideoPlayer.swift index b917dd9..122c466 100644 --- a/Sora/Utils/MediaPlayer/VideoPlayer.swift +++ b/Sora/Utils/MediaPlayer/VideoPlayer.swift @@ -134,7 +134,7 @@ class VideoPlayerViewController: UIViewController { let remainingPercentage = (duration - currentTime) / duration - if remainingPercentage < 0.1 && self.module.metadata.type == "anime" && self.aniListID != 0 { + if remainingPercentage < 0.1 && self.aniListID != 0 { let aniListMutation = AniListMutation() aniListMutation.updateAnimeProgress(animeId: self.aniListID, episodeNumber: self.episodeNumber) { result in switch result { diff --git a/Sora/Utils/Modules/ModuleAdditionSettingsView.swift b/Sora/Utils/Modules/ModuleAdditionSettingsView.swift index b2580e2..2afb2da 100644 --- a/Sora/Utils/Modules/ModuleAdditionSettingsView.swift +++ b/Sora/Utils/Modules/ModuleAdditionSettingsView.swift @@ -176,7 +176,7 @@ struct ModuleAdditionSettingsView: View { let _ = 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 { diff --git a/Sora/Utils/iCloudSyncManager/iCloudSyncManager.swift b/Sora/Utils/iCloudSyncManager/iCloudSyncManager.swift new file mode 100644 index 0000000..9a9c3a1 --- /dev/null +++ b/Sora/Utils/iCloudSyncManager/iCloudSyncManager.swift @@ -0,0 +1,94 @@ +// +// iCloudSyncManager.swift +// Sulfur +// +// Created by Francesco on 17/04/25. +// + +import UIKit + +class iCloudSyncManager { + static let shared = iCloudSyncManager() + + private let defaultsToSync: [String] = [ + "externalPlayer", + "alwaysLandscape", + "rememberPlaySpeed", + "holdSpeedPlayer", + "skipIncrement", + "skipIncrementHold", + "holdForPauseEnabled", + "skip85Visible", + "doubleTapSeekEnabled", + "selectedModuleId", + "mediaColumnsPortrait", + "mediaColumnsLandscape", + "sendPushUpdates", + "sendTraktUpdates", + "bookmarkedItems", + "continueWatchingItems" + ] + + private init() { + setupSync() + + NotificationCenter.default.addObserver(self, selector: #selector(willEnterBackground), name: UIApplication.willResignActiveNotification, object: nil) + } + + private func setupSync() { + NSUbiquitousKeyValueStore.default.synchronize() + + syncFromiCloud() + + 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() + } + + private func syncFromiCloud() { + let iCloud = NSUbiquitousKeyValueStore.default + let defaults = UserDefaults.standard + + for key in defaultsToSync { + 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 defaultsToSync { + 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 { + return + } + + if reason == NSUbiquitousKeyValueStoreServerChange || + reason == NSUbiquitousKeyValueStoreInitialSyncChange { + syncFromiCloud() + } + } + + @objc private func userDefaultsDidChange(_ notification: Notification) { + syncToiCloud() + } +} diff --git a/Sora/Views/LibraryView/LibraryManager.swift b/Sora/Views/LibraryView/LibraryManager.swift index 285524e..6e8e3d3 100644 --- a/Sora/Views/LibraryView/LibraryManager.swift +++ b/Sora/Views/LibraryView/LibraryManager.swift @@ -33,6 +33,14 @@ class LibraryManager: ObservableObject { init() { loadBookmarks() + + NotificationCenter.default.addObserver(self, selector: #selector(handleiCloudSync), name: .iCloudSyncDidComplete, object: nil) + } + + @objc private func handleiCloudSync() { + DispatchQueue.main.async { + self.loadBookmarks() + } } func removeBookmark(item: LibraryItem) { diff --git a/Sora/Views/LibraryView/LibraryView.swift b/Sora/Views/LibraryView/LibraryView.swift index 0048c41..99eb462 100644 --- a/Sora/Views/LibraryView/LibraryView.swift +++ b/Sora/Views/LibraryView/LibraryView.swift @@ -335,9 +335,11 @@ struct ContinueWatchingCell: View { let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(item.fullUrl)") if totalTime > 0 { - currentProgress = lastPlayedTime / totalTime + let ratio = lastPlayedTime / totalTime + // Clamp ratio between 0 and 1: + currentProgress = max(0, min(ratio, 1)) } else { - currentProgress = item.progress + currentProgress = max(0, min(item.progress, 1)) } } } diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index 5511d98..1ddb47b 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -29,6 +29,17 @@ struct EpisodeCell: View { @State private var isLoading: Bool = true @State private var currentProgress: Double = 0.0 + init(episodeIndex: Int, episode: String, episodeID: Int, progress: Double, + itemID: Int, onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void) { + self.episodeIndex = episodeIndex + self.episode = episode + self.episodeID = episodeID + self.progress = progress + self.itemID = itemID + self.onTap = onTap + self.onMarkAllPrevious = onMarkAllPrevious + } + var body: some View { HStack { ZStack { @@ -124,6 +135,10 @@ struct EpisodeCell: View { } private func fetchEpisodeDetails() { + fetchAnimeEpisodeDetails() + } + + private func fetchAnimeEpisodeDetails() { guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(itemID)") else { isLoading = false return @@ -131,7 +146,7 @@ struct EpisodeCell: View { URLSession.custom.dataTask(with: url) { data, _, error in if let error = error { - Logger.shared.log("Failed to fetch episode details: \(error)", type: "Error") + Logger.shared.log("Failed to fetch anime episode details: \(error)", type: "Error") DispatchQueue.main.async { self.isLoading = false } @@ -152,7 +167,7 @@ struct EpisodeCell: View { let episodeDetails = episodes["\(episodeID + 1)"] as? [String: Any], let title = episodeDetails["title"] as? [String: String], let image = episodeDetails["image"] as? String else { - Logger.shared.log("Invalid response format", type: "Error") + Logger.shared.log("Invalid anime response format", type: "Error") DispatchQueue.main.async { self.isLoading = false } diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index e714da3..9b08b39 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -27,6 +27,7 @@ struct MediaInfoView: View { @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 @@ -49,6 +50,8 @@ struct MediaInfoView: View { @EnvironmentObject private var libraryManager: LibraryManager @State private var selectedRange: Range = 0..<100 + @State private var showSettingsMenu = false + @State private var customAniListID: Int? private var isGroupedBySeasons: Bool { return groupedEpisodes().count > 1 @@ -126,6 +129,55 @@ struct MediaInfoView: View { .padding(4) .background(Capsule().fill(Color.accentColor.opacity(0.4))) } + + Menu { + Button(action: { + showCustomIDAlert() + }) { + Label("Set Custom AniList ID", systemImage: "number") + } + + if let customID = 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) + } } } } @@ -368,17 +420,25 @@ struct MediaInfoView: View { buttonRefreshTrigger.toggle() if !hasFetched { - DropManager.shared.showDrop(title: "Fetching Data", subtitle: "Please wait while fetching.", duration: 1.0, icon: UIImage(systemName: "arrow.triangle.2.circlepath")) + DropManager.shared.showDrop(title: "Fetching Data", subtitle: "Please wait while fetching.", duration: 0.5, icon: UIImage(systemName: "arrow.triangle.2.circlepath")) fetchDetails() - fetchItemID(byTitle: title) { result in - switch result { - case .success(let id): - itemID = id - case .failure(let error): - Logger.shared.log("Failed to fetch Item ID: \(error)") - AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch Item ID"]) + + if let savedID = UserDefaults.standard.object(forKey: "custom_anilist_id_\(href)") as? Int { + customAniListID = savedID + itemID = savedID + Logger.shared.log("Using custom AniList ID: \(savedID)", type: "Debug") + } else { + 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)") + AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch AniList ID"]) + } } } + hasFetched = true AnalyticsManager.shared.sendEvent(event: "search", additionalData: ["title": title]) } @@ -631,7 +691,7 @@ struct MediaInfoView: View { 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: 1.0, icon: UIImage(systemName: "xmark")) + DropManager.shared.showDrop(title: "Stream not Found", subtitle: "", duration: 0.5, icon: UIImage(systemName: "xmark")) UINotificationFeedbackGenerator().notificationOccurred(.error) self.isLoading = false @@ -641,11 +701,34 @@ struct MediaInfoView: View { DispatchQueue.main.async { let alert = UIAlertController(title: "Select Server", message: "Choose a server to play from", preferredStyle: .actionSheet) - for (index, stream) in streams.enumerated() { - let quality = "Stream \(index + 1)" - alert.addAction(UIAlertAction(title: quality, style: .default) { _ in - self.playStream(url: stream, fullURL: fullURL, subtitles: subtitles) + 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] + streamUrl = streams[index + 1] + index += 2 + } else { + title = "Stream \(streamIndex)" + streamUrl = streams[index] + index += 1 + } + } else { + title = "Stream \(streamIndex)" + 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)) @@ -772,6 +855,18 @@ struct MediaInfoView: View { } } + 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 { @@ -819,4 +914,33 @@ 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 + if let customID = customAniListID { + textField.text = "\(customID)" + } + } + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + alert.addAction(UIAlertAction(title: "Save", style: .default) { _ in + if let text = alert.textFields?.first?.text, + let id = Int(text) { + customAniListID = id + itemID = id + UserDefaults.standard.set(id, forKey: "custom_anilist_id_\(href)") + Logger.shared.log("Set custom AniList ID: \(id)", type: "General") + } + }) + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let rootVC = window.rootViewController { + findTopViewController.findViewController(rootVC).present(alert, animated: true) + } + } } diff --git a/Sora/Views/SearchView.swift b/Sora/Views/SearchView.swift index cfd739d..ab63ae9 100644 --- a/Sora/Views/SearchView.swift +++ b/Sora/Views/SearchView.swift @@ -311,6 +311,7 @@ struct SearchView: View { } struct SearchBar: View { + @State private var debounceTimer: Timer? @Binding var text: String var onSearchButtonClicked: () -> Void @@ -321,6 +322,14 @@ struct SearchBar: View { .padding(.horizontal, 25) .background(Color(.systemGray6)) .cornerRadius(8) + .onChange(of: text){newValue in + debounceTimer?.invalidate() + // Start a new timer to wait before performing the action + debounceTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in + // Perform the action after the delay (debouncing) + onSearchButtonClicked() + } + } .overlay( HStack { Image(systemName: "magnifyingglass") diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift index 154d1d2..ed21a08 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift @@ -14,13 +14,9 @@ struct SettingsViewGeneral: View { @AppStorage("analyticsEnabled") private var analyticsEnabled: Bool = false @AppStorage("multiThreads") private var multiThreadsEnabled: Bool = false @AppStorage("metadataProviders") private var metadataProviders: String = "AniList" - @AppStorage("CustomDNSProvider") private var customDNSProvider: String = "Cloudflare" - @AppStorage("customPrimaryDNS") private var customPrimaryDNS: String = "" - @AppStorage("customSecondaryDNS") private var customSecondaryDNS: String = "" @AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2 @AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4 - private let customDNSProviderList = ["Cloudflare", "Google", "OpenDNS", "Quad9", "AdGuard", "CleanBrowsing", "ControlD", "Custom"] private let metadataProvidersList = ["AniList"] @EnvironmentObject var settings: Settings diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift index db620b8..1f52970 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift @@ -142,14 +142,48 @@ struct SettingsViewModule: View { } func showAddModuleAlert() { - let alert = UIAlertController(title: "Add Module", message: "Enter the URL of the module file", preferredStyle: .alert) + let pasteboardString = UIPasteboard.general.string ?? "" + + if !pasteboardString.isEmpty { + let clipboardAlert = UIAlertController( + title: "Clipboard Detected", + message: "We found some text in your clipboard. Would you like to use it as the module URL?", + preferredStyle: .alert + ) + + 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 { + rootViewController.present(clipboardAlert, animated: true, completion: nil) + } + + } else { + showManualUrlAlert() + } + } + + func showManualUrlAlert() { + let alert = UIAlertController( + title: "Add Module", + message: "Enter the URL of the module file", + preferredStyle: .alert + ) + alert.addTextField { textField in textField.placeholder = "https://real.url/module.json" } + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil)) alert.addAction(UIAlertAction(title: "Add", style: .default, handler: { _ in - if let url = alert.textFields?.first?.text { - displayModuleView(url: url) + if let url = alert.textFields?.first?.text, !url.isEmpty { + self.displayModuleView(url: url) } })) @@ -158,16 +192,18 @@ struct SettingsViewModule: View { rootViewController.present(alert, animated: true, completion: nil) } } - + func displayModuleView(url: String) { DispatchQueue.main.async { - let addModuleView = ModuleAdditionSettingsView(moduleUrl: url).environmentObject(moduleManager) + 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.rootViewController?.present(hostingController, animated: true, completion: nil) - } + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first { + window.rootViewController?.present(hostingController, animated: true, completion: nil) + } } } + } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift index 78e61a5..d425931 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift @@ -10,13 +10,13 @@ import SwiftUI struct SettingsViewPlayer: View { @AppStorage("externalPlayer") private var externalPlayer: String = "Sora" @AppStorage("alwaysLandscape") private var isAlwaysLandscape = false - @AppStorage("hideNextButton") private var isHideNextButton = false @AppStorage("rememberPlaySpeed") private var isRememberPlaySpeed = false @AppStorage("holdSpeedPlayer") private var holdSpeedPlayer: Double = 2.0 @AppStorage("skipIncrement") private var skipIncrement: Double = 10.0 @AppStorage("skipIncrementHold") private var skipIncrementHold: Double = 30.0 @AppStorage("holdForPauseEnabled") private var holdForPauseEnabled = false @AppStorage("skip85Visible") private var skip85Visible: Bool = true + @AppStorage("doubleTapSeekEnabled") private var doubleTapSeekEnabled: Bool = false private let mediaPlayers = ["Default", "VLC", "OutPlayer", "Infuse", "nPlayer", "Sora"] @@ -38,9 +38,6 @@ struct SettingsViewPlayer: View { } } - Toggle("Hide 'Watch Next' after 5s", isOn: $isHideNextButton) - .tint(.accentColor) - Toggle("Force Landscape", isOn: $isAlwaysLandscape) .tint(.accentColor) @@ -76,6 +73,10 @@ struct SettingsViewPlayer: View { Spacer() Stepper("\(Int(skipIncrementHold))s", value: $skipIncrementHold, in: 5...300, step: 5) } + + Toggle("Double Tap to Seek", isOn: $doubleTapSeekEnabled) + .tint(.accentColor) + Toggle("Show Skip 85s Button", isOn: $skip85Visible) .tint(.accentColor) } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift index 6ec6b94..3332bb1 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift @@ -11,13 +11,18 @@ import Kingfisher struct SettingsViewTrackers: View { @AppStorage("sendPushUpdates") private var isSendPushUpdates = true - - @State private var status: String = "You are not logged in" - @State private var isLoggedIn: Bool = false - @State private var username: String = "" - @State private var isLoading: Bool = false + @State private var anilistStatus: String = "You are not logged in" + @State private var isAnilistLoggedIn: Bool = false + @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: String = "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"), footer: Text("Sora and cranci1 are not affiliated with AniList in any way.\n\nNote that progresses update may not be 100% acurate.")) { @@ -36,31 +41,77 @@ struct SettingsViewTrackers: View { Text("AniList.co") .font(.title2) } - if isLoading { + + if isAnilistLoading { ProgressView() } else { - if isLoggedIn { + if isAnilistLoggedIn { HStack(spacing: 0) { Text("Logged in as ") - Text(username) + Text(anilistUsername) .foregroundColor(profileColor) .font(.body) .fontWeight(.semibold) } } else { - Text(status) + Text(anilistStatus) .multilineTextAlignment(.center) } } - if isLoggedIn { - Toggle("Sync progreses", isOn: $isSendPushUpdates) + + if isAnilistLoggedIn { + Toggle("Sync anime progress", isOn: $isSendPushUpdates) .tint(.accentColor) } - Button(isLoggedIn ? "Log Out from AniList.co" : "Log In with AniList.co") { - if isLoggedIn { - logout() + + Button(isAnilistLoggedIn ? "Log Out from AniList" : "Log In with AniList") { + if isAnilistLoggedIn { + logoutAniList() } else { - login() + loginAniList() + } + } + .font(.body) + } + + Section(header: Text("Trakt"), footer: Text("Sora and cranci1 are not affiliated with Trakt in any way.\n\nNote that progress updates may not be 100% accurate.")) { + HStack() { + KFImage(URL(string: "https://static-00.iconduck.com/assets.00/trakt-icon-2048x2048-2633ksxg.png")) + .placeholder { + RoundedRectangle(cornerRadius: 10) + .fill(Color.gray.opacity(0.3)) + .frame(width: 80, height: 80) + .shimmering() + } + .resizable() + .frame(width: 80, height: 80) + .clipShape(Rectangle()) + .cornerRadius(10) + Text("Trakt.tv") + .font(.title2) + } + + if isTraktLoading { + ProgressView() + } else { + if isTraktLoggedIn { + HStack(spacing: 0) { + Text("Logged in as ") + Text(traktUsername) + .font(.body) + .fontWeight(.semibold) + } + } else { + Text(traktStatus) + .multilineTextAlignment(.center) + } + } + + Button(isTraktLoggedIn ? "Log Out from Trakt" : "Log In with Trakt") { + if isTraktLoggedIn { + logoutTrakt() + } else { + loginTrakt() } } .font(.body) @@ -68,7 +119,8 @@ struct SettingsViewTrackers: View { } .navigationTitle("Trackers") .onAppear { - updateStatus() + updateAniListStatus() + updateTraktStatus() setupNotificationObservers() } .onDisappear { @@ -76,54 +128,167 @@ struct SettingsViewTrackers: View { } } + 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.status = "Authentication successful!" - self.updateStatus() + 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.status = "Login failed: \(error)" + self.anilistStatus = "Login failed: \(error)" } else { - self.status = "Login failed with unknown error" + self.anilistStatus = "Login failed with unknown error" } - self.isLoggedIn = false - self.isLoading = false + 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)" + } else { + self.traktStatus = "Login failed with unknown error" + } + self.isTraktLoggedIn = false + self.isTraktLoading = false } } - func removeNotificationObservers() { - NotificationCenter.default.removeObserver(self, name: AniListToken.authSuccessNotification, object: nil) - NotificationCenter.default.removeObserver(self, name: AniListToken.authFailureNotification, object: nil) + func loginTrakt() { + traktStatus = "Starting authentication..." + isTraktLoading = true + TraktLogin.authenticate() } - func login() { - status = "Starting authentication..." - isLoading = true + func logoutTrakt() { + removeTraktTokenFromKeychain() + traktStatus = "You are not logged in" + isTraktLoggedIn = false + traktUsername = "" + } + + func updateTraktStatus() { + if let token = getTraktTokenFromKeychain() { + isTraktLoggedIn = true + fetchTraktUserInfo(token: token) + } else { + isTraktLoggedIn = false + traktStatus = "You are not logged in" + } + } + + func fetchTraktUserInfo(token: String) { + isTraktLoading = true + let userInfoURL = URL(string: "https://api.trakt.tv/users/settings")! + var request = URLRequest(url: userInfoURL) + 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(TraktToken.clientID, forHTTPHeaderField: "trakt-api-key") + + URLSession.shared.dataTask(with: request) { data, response, 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], + let username = user["username"] as? String { + self.traktUsername = username + self.traktStatus = "Logged in as \(username)" + } + } catch { + self.traktStatus = "Failed to parse response" + } + } + }.resume() + } + + func getTraktTokenFromKeychain() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: TraktToken.serviceName, + kSecAttrAccount as String: TraktToken.accessTokenKey, + 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, + let token = String(data: tokenData, encoding: .utf8) else { + return nil + } + return token + } + + func removeTraktTokenFromKeychain() { + let deleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: TraktToken.serviceName, + kSecAttrAccount as String: TraktToken.accessTokenKey + ] + SecItemDelete(deleteQuery as CFDictionary) + + let refreshDeleteQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: TraktToken.serviceName, + kSecAttrAccount as String: TraktToken.refreshTokenKey + ] + SecItemDelete(refreshDeleteQuery as CFDictionary) + } + + func loginAniList() { + anilistStatus = "Starting authentication..." + isAnilistLoading = true AniListLogin.authenticate() } - func logout() { + func logoutAniList() { removeTokenFromKeychain() - status = "You are not logged in" - isLoggedIn = false - username = "" + anilistStatus = "You are not logged in" + isAnilistLoggedIn = false + anilistUsername = "" profileColor = .primary } - func updateStatus() { + func updateAniListStatus() { if let token = getTokenFromKeychain() { - isLoggedIn = true + isAnilistLoggedIn = true fetchUserInfo(token: token) } else { - isLoggedIn = false - status = "You are not logged in" + isAnilistLoggedIn = false + anilistStatus = "You are not logged in" } } func fetchUserInfo(token: String) { - isLoading = true + isAnilistLoading = true let userInfoURL = URL(string: "https://graphql.anilist.co")! var request = URLRequest(url: userInfoURL) request.httpMethod = "POST" @@ -146,22 +311,22 @@ struct SettingsViewTrackers: View { do { request.httpBody = try JSONSerialization.data(withJSONObject: body, options: []) } catch { - status = "Failed to serialize request" + anilistStatus = "Failed to serialize request" Logger.shared.log("Failed to serialize request", type: "Error") - isLoading = false + isAnilistLoading = false return } URLSession.shared.dataTask(with: request) { data, response, error in DispatchQueue.main.async { - isLoading = false + isAnilistLoading = false if let error = error { - status = "Error: \(error.localizedDescription)" + anilistStatus = "Error: \(error.localizedDescription)" Logger.shared.log("Error: \(error.localizedDescription)", type: "Error") return } guard let data = data else { - status = "No data received" + anilistStatus = "No data received" Logger.shared.log("No data received", type: "Error") return } @@ -173,15 +338,15 @@ struct SettingsViewTrackers: View { let options = viewer["options"] as? [String: Any], let colorName = options["profileColor"] as? String { - username = name + anilistUsername = name profileColor = colorFromName(colorName) - status = "Logged in as \(name)" + anilistStatus = "Logged in as \(name)" } else { - status = "Unexpected response format!" + anilistStatus = "Unexpected response format!" Logger.shared.log("Unexpected response format!", type: "Error") } } catch { - status = "Failed to parse response: \(error.localizedDescription)" + anilistStatus = "Failed to parse response: \(error.localizedDescription)" Logger.shared.log("Failed to parse response: \(error.localizedDescription)", type: "Error") } } diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index a2d7091..8e19b85 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -35,6 +35,8 @@ 133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133F55BA2D33B55100E08EEA /* LibraryManager.swift */; }; 1359ED142D76F49900C13034 /* finTopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1359ED132D76F49900C13034 /* finTopView.swift */; }; 135CCBE22D4D1138008B9C0E /* SettingsViewPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */; }; + 136BBE7E2DB102D600906B5E /* iCloudSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136BBE7D2DB102D600906B5E /* iCloudSyncManager.swift */; }; + 136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136BBE7F2DB1038000906B5E /* Notification+Name.swift */; }; 138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */; }; 138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */; }; 139935662D468C450065CEFF /* ModuleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139935652D468C450065CEFF /* ModuleManager.swift */; }; @@ -56,10 +58,13 @@ 13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB7CC22D7D99C0004371D3 /* SubtitleSettingsManager.swift */; }; 13DB7CEC2D7DED5D004371D3 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB7CEB2D7DED5D004371D3 /* DownloadManager.swift */; }; 13DC0C462D302C7500D0F966 /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DC0C452D302C7500D0F966 /* VideoPlayer.swift */; }; + 13E62FC22DABC5830007E259 /* Trakt-Login.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13E62FC12DABC5830007E259 /* Trakt-Login.swift */; }; + 13E62FC42DABC58C0007E259 /* Trakt-Token.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13E62FC32DABC58C0007E259 /* Trakt-Token.swift */; }; + 13E62FC72DABFE900007E259 /* TraktPushUpdates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13E62FC62DABFE900007E259 /* TraktPushUpdates.swift */; }; 13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */; }; 13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */; }; 13EA2BD92D32D98400C1EBD7 /* NormalPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */; }; - 1E3F5EC82D9F16B7003F310F /* VerticalBrightnessSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E3F5EC72D9F16B7003F310F /* VerticalBrightnessSlider.swift */; }; + 1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E26E9E62DA9577900B9DC02 /* VolumeSlider.swift */; }; 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; }; 1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */; }; 73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */; }; @@ -93,6 +98,8 @@ 133F55BA2D33B55100E08EEA /* LibraryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryManager.swift; sourceTree = ""; }; 1359ED132D76F49900C13034 /* finTopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = finTopView.swift; sourceTree = ""; }; 135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewPlayer.swift; sourceTree = ""; }; + 136BBE7D2DB102D600906B5E /* iCloudSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iCloudSyncManager.swift; sourceTree = ""; }; + 136BBE7F2DB1038000906B5E /* Notification+Name.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Name.swift"; sourceTree = ""; }; 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCell.swift; sourceTree = ""; }; 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressBar.swift; sourceTree = ""; }; 139935652D468C450065CEFF /* ModuleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleManager.swift; sourceTree = ""; }; @@ -114,10 +121,13 @@ 13DB7CEB2D7DED5D004371D3 /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = ""; }; 13DC0C412D2EC9BA00D0F966 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 13DC0C452D302C7500D0F966 /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = ""; }; + 13E62FC12DABC5830007E259 /* Trakt-Login.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Trakt-Login.swift"; sourceTree = ""; }; + 13E62FC32DABC58C0007E259 /* Trakt-Token.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Trakt-Token.swift"; sourceTree = ""; }; + 13E62FC62DABFE900007E259 /* TraktPushUpdates.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TraktPushUpdates.swift; sourceTree = ""; }; 13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomPlayer.swift; sourceTree = ""; }; 13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = ""; }; 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NormalPlayer.swift; sourceTree = ""; }; - 1E3F5EC72D9F16B7003F310F /* VerticalBrightnessSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalBrightnessSlider.swift; sourceTree = ""; }; + 1E26E9E62DA9577900B9DC02 /* VolumeSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeSlider.swift; sourceTree = ""; }; 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = ""; }; 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = ""; }; 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = ""; }; @@ -141,6 +151,7 @@ 13103E802D589D6C000F0673 /* Tracking Services */ = { isa = PBXGroup; children = ( + 13E62FBF2DABC3A20007E259 /* Trakt */, 13103E812D589D77000F0673 /* AniList */, ); path = "Tracking Services"; @@ -250,6 +261,7 @@ 133D7C852D2BE2640075467E /* Utils */ = { isa = PBXGroup; children = ( + 136BBE7C2DB102BE00906B5E /* iCloudSyncManager */, 13DB7CEA2D7DED50004371D3 /* DownloadManager */, 13C0E5E82D5F85DD00E7F619 /* ContinueWatching */, 13103E8C2D58E037000F0673 /* SkeletonCells */, @@ -268,6 +280,7 @@ isa = PBXGroup; children = ( 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */, + 136BBE7F2DB1038000906B5E /* Notification+Name.swift */, 1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */, 133D7C872D2BE2640075467E /* URLSession.swift */, 1359ED132D76F49900C13034 /* finTopView.swift */, @@ -308,6 +321,14 @@ path = LibraryView; sourceTree = ""; }; + 136BBE7C2DB102BE00906B5E /* iCloudSyncManager */ = { + isa = PBXGroup; + children = ( + 136BBE7D2DB102D600906B5E /* iCloudSyncManager.swift */, + ); + path = iCloudSyncManager; + sourceTree = ""; + }; 1384DCDF2D89BE870094797A /* Helpers */ = { isa = PBXGroup; children = ( @@ -395,6 +416,32 @@ path = MediaPlayer; sourceTree = ""; }; + 13E62FBF2DABC3A20007E259 /* Trakt */ = { + isa = PBXGroup; + children = ( + 13E62FC52DABFE810007E259 /* Mutations */, + 13E62FC02DABC3A90007E259 /* Auth */, + ); + path = Trakt; + sourceTree = ""; + }; + 13E62FC02DABC3A90007E259 /* Auth */ = { + isa = PBXGroup; + children = ( + 13E62FC12DABC5830007E259 /* Trakt-Login.swift */, + 13E62FC32DABC58C0007E259 /* Trakt-Token.swift */, + ); + path = Auth; + sourceTree = ""; + }; + 13E62FC52DABFE810007E259 /* Mutations */ = { + isa = PBXGroup; + children = ( + 13E62FC62DABFE900007E259 /* TraktPushUpdates.swift */, + ); + path = Mutations; + sourceTree = ""; + }; 13EA2BD02D32D97400C1EBD7 /* CustomPlayer */ = { isa = PBXGroup; children = ( @@ -408,7 +455,7 @@ 13EA2BD22D32D97400C1EBD7 /* Components */ = { isa = PBXGroup; children = ( - 1E3F5EC72D9F16B7003F310F /* VerticalBrightnessSlider.swift */, + 1E26E9E62DA9577900B9DC02 /* VolumeSlider.swift */, 13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */, 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */, ); @@ -506,6 +553,9 @@ 133D7C902D2BE2640075467E /* SettingsView.swift in Sources */, 132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */, 13CBEFDA2D5F7D1200D011EE /* String.swift in Sources */, + 136BBE7E2DB102D600906B5E /* iCloudSyncManager.swift in Sources */, + 1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */, + 136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */, 13DB46902D900A38008CBC03 /* URL.swift in Sources */, 130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */, 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */, @@ -532,12 +582,14 @@ 133D7C942D2BE2640075467E /* JSController.swift in Sources */, 133D7C922D2BE2640075467E /* URLSession.swift in Sources */, 133D7C912D2BE2640075467E /* SettingsViewModule.swift in Sources */, + 13E62FC22DABC5830007E259 /* Trakt-Login.swift in Sources */, 130217CC2D81C55E0011EFF5 /* DownloadView.swift in Sources */, 133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */, + 13E62FC42DABC58C0007E259 /* Trakt-Token.swift in Sources */, 133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */, + 13E62FC72DABFE900007E259 /* TraktPushUpdates.swift in Sources */, 133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */, 138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */, - 1E3F5EC82D9F16B7003F310F /* VerticalBrightnessSlider.swift in Sources */, 13DB468D2D90093A008CBC03 /* Anilist-Login.swift in Sources */, 73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */, 132AF1232D9995C300A0140B /* JSController-Details.swift in Sources */,