New testflight build (#84)
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 384 B |
|
Before Width: | Height: | Size: 565 B |
|
Before Width: | Height: | Size: 832 B |
|
Before Width: | Height: | Size: 832 B |
|
Before Width: | Height: | Size: 832 B |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
|
@ -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"
|
||||
}
|
||||
],
|
||||
|
|
|
|||
BIN
Sora/Assets.xcassets/AppIcon.appiconset/darkmode.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
Sora/Assets.xcassets/AppIcon.appiconset/lightmode.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
Sora/Assets.xcassets/AppIcon.appiconset/tinting.png
Normal file
|
After Width: | Height: | Size: 104 KiB |
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
34
Sora/Tracking Services/Trakt/Auth/Trakt-Login.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
168
Sora/Tracking Services/Trakt/Auth/Trakt-Token.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
131
Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
14
Sora/Utils/Extensions/Notification+Name.swift
Normal 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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}()
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
94
Sora/Utils/iCloudSyncManager/iCloudSyncManager.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */,
|
||||
|
|
|
|||