New testflight build (#84)

This commit is contained in:
cranci 2025-04-17 17:05:56 +02:00 committed by GitHub
commit e076be593d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
53 changed files with 1845 additions and 833 deletions

View file

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 565 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 832 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 832 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 832 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -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"
}
],

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

View file

@ -2,6 +2,10 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.icloud-container-identifiers</key>
<array/>
<key>com.apple.developer.ubiquity-kvstore-identifier</key>
<string>$(TeamIdentifierPrefix)$(CFBundleIdentifier)</string>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>

View file

@ -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")
}
}
}

View file

@ -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))

View file

@ -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")
}
}
}

View file

@ -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
}
}

View file

@ -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, Error>) -> 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()
}
}

View file

@ -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 {

View file

@ -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()

View file

@ -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);
},

View file

@ -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")
}

View file

@ -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)
}()
}

View file

@ -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)
}
}

View file

@ -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
}
}
class VolumeViewModel: ObservableObject {
@Published var value: Double = 0.0
}

View file

@ -11,14 +11,12 @@ import SwiftUI
struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
@Binding var value: T
@Binding var bufferValue: T // NEW
let inRange: ClosedRange<T>
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<T: BinaryFloatingPoint>: 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<T: BinaryFloatingPoint>: View {
})
}
// Time labels
HStack {
let shouldShowHours = inRange.upperBound >= 3600
Text(value.asTimeString(style: .positional, showHours: shouldShowHours))
@ -79,11 +54,10 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: 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<T: BinaryFloatingPoint>: 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<T: BinaryFloatingPoint>: 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
}
}

View file

@ -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<T: BinaryFloatingPoint>: View {
@Binding var value: T
let inRange: ClosedRange<T>
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
}
}

View file

@ -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<T: BinaryFloatingPoint>: View {
@Binding var value: T
let inRange: ClosedRange<T>
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..<lowThreshold:
return "speaker.fill"
case lowThreshold..<midThreshold:
return "speaker.wave.1.fill"
case midThreshold..<highThreshold:
return "speaker.wave.2.fill"
default:
return "speaker.wave.3.fill"
}
}
private func handleIconTap() {
let currentProgress = localRealProgress + localTempProgress
withAnimation {
if currentProgress <= 0 {
value = lastVolumeValue
localRealProgress = progress(for: lastVolumeValue)
localTempProgress = 0
} else {
lastVolumeValue = sliderValueInRange()
value = T(0)
localRealProgress = 0
localTempProgress = 0
}
}
}
private var animation: Animation {
isActive
? .spring()
: .spring(response: 0.5, dampingFraction: 0.5, blendDuration: 0.6)
}
private func progress(for val: T) -> 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)
}
}

File diff suppressed because it is too large Load diff

View file

@ -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 {

View file

@ -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 {

View file

@ -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()
}
}

View file

@ -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) {

View file

@ -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))
}
}
}

View file

@ -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
}

View file

@ -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<Int> = 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<Int, Error>) -> 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)
}
}
}

View file

@ -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")

View file

@ -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

View file

@ -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)
}
}
}
}

View file

@ -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)
}

View file

@ -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")
}
}

View file

@ -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 = "<group>"; };
1359ED132D76F49900C13034 /* finTopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = finTopView.swift; sourceTree = "<group>"; };
135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewPlayer.swift; sourceTree = "<group>"; };
136BBE7D2DB102D600906B5E /* iCloudSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iCloudSyncManager.swift; sourceTree = "<group>"; };
136BBE7F2DB1038000906B5E /* Notification+Name.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Name.swift"; sourceTree = "<group>"; };
138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EpisodeCell.swift; sourceTree = "<group>"; };
138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CircularProgressBar.swift; sourceTree = "<group>"; };
139935652D468C450065CEFF /* ModuleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModuleManager.swift; sourceTree = "<group>"; };
@ -114,10 +121,13 @@
13DB7CEB2D7DED5D004371D3 /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = "<group>"; };
13DC0C412D2EC9BA00D0F966 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
13DC0C452D302C7500D0F966 /* VideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayer.swift; sourceTree = "<group>"; };
13E62FC12DABC5830007E259 /* Trakt-Login.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Trakt-Login.swift"; sourceTree = "<group>"; };
13E62FC32DABC58C0007E259 /* Trakt-Token.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Trakt-Token.swift"; sourceTree = "<group>"; };
13E62FC62DABFE900007E259 /* TraktPushUpdates.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TraktPushUpdates.swift; sourceTree = "<group>"; };
13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CustomPlayer.swift; sourceTree = "<group>"; };
13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Double+Extension.swift"; sourceTree = "<group>"; };
13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NormalPlayer.swift; sourceTree = "<group>"; };
1E3F5EC72D9F16B7003F310F /* VerticalBrightnessSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerticalBrightnessSlider.swift; sourceTree = "<group>"; };
1E26E9E62DA9577900B9DC02 /* VolumeSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeSlider.swift; sourceTree = "<group>"; };
1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = "<group>"; };
1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = "<group>"; };
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = "<group>"; };
@ -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 = "<group>";
};
136BBE7C2DB102BE00906B5E /* iCloudSyncManager */ = {
isa = PBXGroup;
children = (
136BBE7D2DB102D600906B5E /* iCloudSyncManager.swift */,
);
path = iCloudSyncManager;
sourceTree = "<group>";
};
1384DCDF2D89BE870094797A /* Helpers */ = {
isa = PBXGroup;
children = (
@ -395,6 +416,32 @@
path = MediaPlayer;
sourceTree = "<group>";
};
13E62FBF2DABC3A20007E259 /* Trakt */ = {
isa = PBXGroup;
children = (
13E62FC52DABFE810007E259 /* Mutations */,
13E62FC02DABC3A90007E259 /* Auth */,
);
path = Trakt;
sourceTree = "<group>";
};
13E62FC02DABC3A90007E259 /* Auth */ = {
isa = PBXGroup;
children = (
13E62FC12DABC5830007E259 /* Trakt-Login.swift */,
13E62FC32DABC58C0007E259 /* Trakt-Token.swift */,
);
path = Auth;
sourceTree = "<group>";
};
13E62FC52DABFE810007E259 /* Mutations */ = {
isa = PBXGroup;
children = (
13E62FC62DABFE900007E259 /* TraktPushUpdates.swift */,
);
path = Mutations;
sourceTree = "<group>";
};
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 */,