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] Local Library
|
||||||
- [x] Streams support (Jellyfin/Plex like servers)
|
- [x] Streams support (Jellyfin/Plex like servers)
|
||||||
- [x] External Media players (VLC, infuse, Outplayer, nPlayer)
|
- [x] External Media players (VLC, infuse, Outplayer, nPlayer)
|
||||||
|
- [x] Tracking Services (AniList, Trakt)
|
||||||
|
|
||||||
## Frequently Asked Questions
|
## 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" : [
|
"images" : [
|
||||||
{
|
{
|
||||||
"filename" : "40-2.png",
|
"filename" : "lightmode.png",
|
||||||
"idiom" : "iphone",
|
"idiom" : "universal",
|
||||||
"scale" : "2x",
|
"platform" : "ios",
|
||||||
"size" : "20x20"
|
"size" : "1024x1024"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "60.png",
|
"appearances" : [
|
||||||
"idiom" : "iphone",
|
{
|
||||||
"scale" : "3x",
|
"appearance" : "luminosity",
|
||||||
"size" : "20x20"
|
"value" : "dark"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filename" : "darkmode.png",
|
||||||
|
"idiom" : "universal",
|
||||||
|
"platform" : "ios",
|
||||||
|
"size" : "1024x1024"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"filename" : "58.png",
|
"appearances" : [
|
||||||
"idiom" : "iphone",
|
{
|
||||||
"scale" : "2x",
|
"appearance" : "luminosity",
|
||||||
"size" : "29x29"
|
"value" : "tinted"
|
||||||
},
|
}
|
||||||
{
|
],
|
||||||
"filename" : "87.png",
|
"filename" : "tinting.png",
|
||||||
"idiom" : "iphone",
|
"idiom" : "universal",
|
||||||
"scale" : "3x",
|
"platform" : "ios",
|
||||||
"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",
|
|
||||||
"size" : "1024x1024"
|
"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">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<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>
|
<key>com.apple.security.app-sandbox</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.network.client</key>
|
<key>com.apple.security.network.client</key>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ struct SoraApp: App {
|
||||||
@StateObject private var moduleManager = ModuleManager()
|
@StateObject private var moduleManager = ModuleManager()
|
||||||
@StateObject private var librarykManager = LibraryManager()
|
@StateObject private var librarykManager = LibraryManager()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
_ = iCloudSyncManager.shared
|
||||||
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
|
|
@ -64,12 +68,25 @@ struct SoraApp: App {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
AniListToken.exchangeAuthorizationCodeForToken(code: code) { success in
|
switch url.host {
|
||||||
if success {
|
case "anilist":
|
||||||
Logger.shared.log("Token exchange successful")
|
AniListToken.exchangeAuthorizationCodeForToken(code: code) { success in
|
||||||
} else {
|
if success {
|
||||||
Logger.shared.log("Token exchange failed", type: "Error")
|
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 {
|
if let data = data {
|
||||||
do {
|
do {
|
||||||
let responseJSON = try JSONSerialization.jsonObject(with: data, options: [])
|
_ = try JSONSerialization.jsonObject(with: data, options: [])
|
||||||
print("Successfully updated anime progress")
|
Logger.shared.log("Successfully updated anime progress", type: "Debug")
|
||||||
print(responseJSON)
|
|
||||||
completion(.success(()))
|
completion(.success(()))
|
||||||
} catch {
|
} catch {
|
||||||
completion(.failure(error))
|
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()
|
static let shared = ContinueWatchingManager()
|
||||||
private let storageKey = "continueWatchingItems"
|
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) {
|
func save(item: ContinueWatchingItem) {
|
||||||
if item.progress >= 0.9 {
|
if item.progress >= 0.9 {
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,6 @@ import Foundation
|
||||||
import FFmpegSupport
|
import FFmpegSupport
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
extension Notification.Name {
|
|
||||||
static let DownloadManagerStatusUpdate = Notification.Name("DownloadManagerStatusUpdate")
|
|
||||||
}
|
|
||||||
|
|
||||||
class DownloadManager {
|
class DownloadManager {
|
||||||
static let shared = DownloadManager()
|
static let shared = DownloadManager()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ extension JSContext {
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupFetchV2() {
|
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 {
|
guard let url = URL(string: urlString) else {
|
||||||
Logger.shared.log("Invalid URL", type: "Error")
|
Logger.shared.log("Invalid URL", type: "Error")
|
||||||
reject.call(withArguments: ["Invalid URL"])
|
reject.call(withArguments: ["Invalid URL"])
|
||||||
|
|
@ -104,8 +104,8 @@ extension JSContext {
|
||||||
request.setValue(value, forHTTPHeaderField: key)
|
request.setValue(value, forHTTPHeaderField: key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Logger.shared.log("Redirect value is \(redirect.boolValue)",type:"Error")
|
||||||
let task = URLSession.custom.downloadTask(with: request) { tempFileURL, response, error in
|
let task = URLSession.fetchData(allowRedirects: redirect.boolValue).downloadTask(with: request) { tempFileURL, response, error in
|
||||||
if let error = error {
|
if let error = error {
|
||||||
Logger.shared.log("Network error in fetchV2NativeFunction: \(error.localizedDescription)", type: "Error")
|
Logger.shared.log("Network error in fetchV2NativeFunction: \(error.localizedDescription)", type: "Error")
|
||||||
reject.call(withArguments: [error.localizedDescription])
|
reject.call(withArguments: [error.localizedDescription])
|
||||||
|
|
@ -117,6 +117,12 @@ extension JSContext {
|
||||||
reject.call(withArguments: ["No data"])
|
reject.call(withArguments: ["No data"])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// initialise return Object
|
||||||
|
var responseDict: [String: Any] = [
|
||||||
|
"status": (response as? HTTPURLResponse)?.statusCode ?? 0,
|
||||||
|
"headers": (response as? HTTPURLResponse)?.allHeaderFields ?? [:],
|
||||||
|
"body": ""
|
||||||
|
]
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let data = try Data(contentsOf: tempFileURL)
|
let data = try Data(contentsOf: tempFileURL)
|
||||||
|
|
@ -126,12 +132,15 @@ extension JSContext {
|
||||||
reject.call(withArguments: ["Response exceeds maximum size"])
|
reject.call(withArguments: ["Response exceeds maximum size"])
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if let text = String(data: data, encoding: .utf8) {
|
if let text = String(data: data, encoding: .utf8) {
|
||||||
resolve.call(withArguments: [text])
|
|
||||||
|
responseDict["body"] = text
|
||||||
|
resolve.call(withArguments: [responseDict])
|
||||||
} else {
|
} 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")
|
Logger.shared.log("Unable to decode data to text", type: "Error")
|
||||||
reject.call(withArguments: ["Unable to decode data"])
|
resolve.call(withArguments: [responseDict])
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -146,35 +155,22 @@ extension JSContext {
|
||||||
self.setObject(fetchV2NativeFunction, forKeyedSubscript: "fetchV2Native" as NSString)
|
self.setObject(fetchV2NativeFunction, forKeyedSubscript: "fetchV2Native" as NSString)
|
||||||
|
|
||||||
let fetchv2Definition = """
|
let fetchv2Definition = """
|
||||||
function fetchv2(url, headers = {}, method = "GET", body = null) {
|
function fetchv2(url, headers = {}, method = "GET", body = null, redirect = true ) {
|
||||||
if (method === "GET") {
|
|
||||||
return new Promise(function(resolve, reject) {
|
|
||||||
fetchV2Native(url, headers, method, null, function(rawText) { // Pass `null` explicitly
|
var processedBody = null;
|
||||||
const responseObj = {
|
if(method != "GET")
|
||||||
_data: rawText,
|
{
|
||||||
text: function() {
|
|
||||||
return Promise.resolve(this._data);
|
|
||||||
},
|
|
||||||
json: function() {
|
|
||||||
try {
|
|
||||||
return Promise.resolve(JSON.parse(this._data));
|
|
||||||
} catch (e) {
|
|
||||||
return Promise.reject("JSON parse error: " + e.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
resolve(responseObj);
|
|
||||||
}, reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure body is properly serialized
|
// Ensure body is properly serialized
|
||||||
const processedBody = body ? JSON.stringify(body) : null;
|
processedBody = body ? JSON.stringify(body) : null
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise(function(resolve, reject) {
|
return new Promise(function(resolve, reject) {
|
||||||
fetchV2Native(url, headers, method, processedBody, function(rawText) {
|
fetchV2Native(url, headers, method, processedBody, redirect, function(rawText) {
|
||||||
const responseObj = {
|
const responseObj = {
|
||||||
_data: rawText,
|
headers: rawText.headers,
|
||||||
|
status: rawText.status,
|
||||||
|
_data: rawText.body,
|
||||||
text: function() {
|
text: function() {
|
||||||
return Promise.resolve(this._data);
|
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
|
func mapToDevice(identifier: String) -> String { // swiftlint:disable:this cyclomatic_complexity
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
switch identifier {
|
switch identifier {
|
||||||
case "iPod5,1": return "iPod touch (5th generation)"
|
case "iPod5,1":
|
||||||
case "iPod7,1": return "iPod touch (6th generation)"
|
return "iPod touch (5th generation)"
|
||||||
case "iPod9,1": return "iPod touch (7th generation)"
|
case "iPod7,1":
|
||||||
case "iPhone3,1", "iPhone3,2", "iPhone3,3": return "iPhone 4"
|
return "iPod touch (6th generation)"
|
||||||
case "iPhone4,1": return "iPhone 4s"
|
case "iPod9,1":
|
||||||
case "iPhone5,1", "iPhone5,2": return "iPhone 5"
|
return "iPod touch (7th generation)"
|
||||||
case "iPhone5,3", "iPhone5,4": return "iPhone 5c"
|
case "iPhone3,1", "iPhone3,2", "iPhone3,3":
|
||||||
case "iPhone6,1", "iPhone6,2": return "iPhone 5s"
|
return "iPhone 4"
|
||||||
case "iPhone7,2": return "iPhone 6"
|
case "iPhone4,1":
|
||||||
case "iPhone7,1": return "iPhone 6 Plus"
|
return "iPhone 4s"
|
||||||
case "iPhone8,1": return "iPhone 6s"
|
case "iPhone5,1", "iPhone5,2":
|
||||||
case "iPhone8,2": return "iPhone 6s Plus"
|
return "iPhone 5"
|
||||||
case "iPhone9,1", "iPhone9,3": return "iPhone 7"
|
case "iPhone5,3", "iPhone5,4":
|
||||||
case "iPhone9,2", "iPhone9,4": return "iPhone 7 Plus"
|
return "iPhone 5c"
|
||||||
case "iPhone10,1", "iPhone10,4": return "iPhone 8"
|
case "iPhone6,1", "iPhone6,2":
|
||||||
case "iPhone10,2", "iPhone10,5": return "iPhone 8 Plus"
|
return "iPhone 5s"
|
||||||
case "iPhone10,3", "iPhone10,6": return "iPhone X"
|
case "iPhone7,2":
|
||||||
case "iPhone11,2": return "iPhone XS"
|
return "iPhone 6"
|
||||||
case "iPhone11,4", "iPhone11,6": return "iPhone XS Max"
|
case "iPhone7,1":
|
||||||
case "iPhone11,8": return "iPhone XR"
|
return "iPhone 6 Plus"
|
||||||
case "iPhone12,1": return "iPhone 11"
|
case "iPhone8,1":
|
||||||
case "iPhone12,3": return "iPhone 11 Pro"
|
return "iPhone 6s"
|
||||||
case "iPhone12,5": return "iPhone 11 Pro Max"
|
case "iPhone8,2":
|
||||||
case "iPhone13,1": return "iPhone 12 mini"
|
return "iPhone 6s Plus"
|
||||||
case "iPhone13,2": return "iPhone 12"
|
case "iPhone9,1", "iPhone9,3":
|
||||||
case "iPhone13,3": return "iPhone 12 Pro"
|
return "iPhone 7"
|
||||||
case "iPhone13,4": return "iPhone 12 Pro Max"
|
case "iPhone9,2", "iPhone9,4":
|
||||||
case "iPhone14,4": return "iPhone 13 mini"
|
return "iPhone 7 Plus"
|
||||||
case "iPhone14,5": return "iPhone 13"
|
case "iPhone10,1", "iPhone10,4":
|
||||||
case "iPhone14,2": return "iPhone 13 Pro"
|
return "iPhone 8"
|
||||||
case "iPhone14,3": return "iPhone 13 Pro Max"
|
case "iPhone10,2", "iPhone10,5":
|
||||||
case "iPhone14,7": return "iPhone 14"
|
return "iPhone 8 Plus"
|
||||||
case "iPhone14,8": return "iPhone 14 Plus"
|
case "iPhone10,3", "iPhone10,6":
|
||||||
case "iPhone15,2": return "iPhone 14 Pro"
|
return "iPhone X"
|
||||||
case "iPhone15,3": return "iPhone 14 Pro Max"
|
case "iPhone11,2":
|
||||||
case "iPhone15,4": return "iPhone 15"
|
return "iPhone XS"
|
||||||
case "iPhone15,5": return "iPhone 15 Plus"
|
case "iPhone11,4", "iPhone11,6":
|
||||||
case "iPhone16,1": return "iPhone 15 Pro"
|
return "iPhone XS Max"
|
||||||
case "iPhone16,2": return "iPhone 15 Pro Max"
|
case "iPhone11,8":
|
||||||
case "iPhone17,3": return "iPhone 16"
|
return "iPhone XR"
|
||||||
case "iPhone17,4": return "iPhone 16 Plus"
|
case "iPhone12,1":
|
||||||
case "iPhone17,1": return "iPhone 16 Pro"
|
return "iPhone 11"
|
||||||
case "iPhone17,2": return "iPhone 16 Pro Max"
|
case "iPhone12,3":
|
||||||
case "iPhone8,4": return "iPhone SE"
|
return "iPhone 11 Pro"
|
||||||
case "iPhone12,8": return "iPhone SE (2nd generation)"
|
case "iPhone12,5":
|
||||||
case "iPhone14,6": return "iPhone SE (3rd generation)"
|
return "iPhone 11 Pro Max"
|
||||||
case "iPad2,1", "iPad2,2", "iPad2,3", "iPad2,4": return "iPad 2"
|
case "iPhone13,1":
|
||||||
case "iPad3,1", "iPad3,2", "iPad3,3": return "iPad (3rd generation)"
|
return "iPhone 12 mini"
|
||||||
case "iPad3,4", "iPad3,5", "iPad3,6": return "iPad (4th generation)"
|
case "iPhone13,2":
|
||||||
case "iPad6,11", "iPad6,12": return "iPad (5th generation)"
|
return "iPhone 12"
|
||||||
case "iPad7,5", "iPad7,6": return "iPad (6th generation)"
|
case "iPhone13,3":
|
||||||
case "iPad7,11", "iPad7,12": return "iPad (7th generation)"
|
return "iPhone 12 Pro"
|
||||||
case "iPad11,6", "iPad11,7": return "iPad (8th generation)"
|
case "iPhone13,4":
|
||||||
case "iPad12,1", "iPad12,2": return "iPad (9th generation)"
|
return "iPhone 12 Pro Max"
|
||||||
case "iPad13,18", "iPad13,19": return "iPad (10th generation)"
|
case "iPhone14,4":
|
||||||
case "iPad4,1", "iPad4,2", "iPad4,3": return "iPad Air"
|
return "iPhone 13 mini"
|
||||||
case "iPad5,3", "iPad5,4": return "iPad Air 2"
|
case "iPhone14,5":
|
||||||
case "iPad11,3", "iPad11,4": return "iPad Air (3rd generation)"
|
return "iPhone 13"
|
||||||
case "iPad13,1", "iPad13,2": return "iPad Air (4th generation)"
|
case "iPhone14,2":
|
||||||
case "iPad13,16", "iPad13,17": return "iPad Air (5th generation)"
|
return "iPhone 13 Pro"
|
||||||
case "iPad14,8", "iPad14,9": return "iPad Air (11-inch) (M2)"
|
case "iPhone14,3":
|
||||||
case "iPad14,10", "iPad14,11": return "iPad Air (13-inch) (M2)"
|
return "iPhone 13 Pro Max"
|
||||||
case "iPad2,5", "iPad2,6", "iPad2,7": return "iPad mini"
|
case "iPhone14,7":
|
||||||
case "iPad4,4", "iPad4,5", "iPad4,6": return "iPad mini 2"
|
return "iPhone 14"
|
||||||
case "iPad4,7", "iPad4,8", "iPad4,9": return "iPad mini 3"
|
case "iPhone14,8":
|
||||||
case "iPad5,1", "iPad5,2": return "iPad mini 4"
|
return "iPhone 14 Plus"
|
||||||
case "iPad11,1", "iPad11,2": return "iPad mini (5th generation)"
|
case "iPhone15,2":
|
||||||
case "iPad14,1", "iPad14,2": return "iPad mini (6th generation)"
|
return "iPhone 14 Pro"
|
||||||
case "iPad16,1", "iPad16,2": return "iPad mini (A17 Pro)"
|
case "iPhone15,3":
|
||||||
case "iPad6,3", "iPad6,4": return "iPad Pro (9.7-inch)"
|
return "iPhone 14 Pro Max"
|
||||||
case "iPad7,3", "iPad7,4": return "iPad Pro (10.5-inch)"
|
case "iPhone15,4":
|
||||||
case "iPad8,1", "iPad8,2", "iPad8,3", "iPad8,4": return "iPad Pro (11-inch) (1st generation)"
|
return "iPhone 15"
|
||||||
case "iPad8,9", "iPad8,10": return "iPad Pro (11-inch) (2nd generation)"
|
case "iPhone15,5":
|
||||||
case "iPad13,4", "iPad13,5", "iPad13,6", "iPad13,7": return "iPad Pro (11-inch) (3rd generation)"
|
return "iPhone 15 Plus"
|
||||||
case "iPad14,3", "iPad14,4": return "iPad Pro (11-inch) (4th generation)"
|
case "iPhone16,1":
|
||||||
case "iPad16,3", "iPad16,4": return "iPad Pro (11-inch) (M4)"
|
return "iPhone 15 Pro"
|
||||||
case "iPad6,7", "iPad6,8": return "iPad Pro (12.9-inch) (1st generation)"
|
case "iPhone16,2":
|
||||||
case "iPad7,1", "iPad7,2": return "iPad Pro (12.9-inch) (2nd generation)"
|
return "iPhone 15 Pro Max"
|
||||||
case "iPad8,5", "iPad8,6", "iPad8,7", "iPad8,8": return "iPad Pro (12.9-inch) (3rd generation)"
|
case "iPhone17,3":
|
||||||
case "iPad8,11", "iPad8,12": return "iPad Pro (12.9-inch) (4th generation)"
|
return "iPhone 16"
|
||||||
case "iPad13,8", "iPad13,9", "iPad13,10", "iPad13,11":return "iPad Pro (12.9-inch) (5th generation)"
|
case "iPhone17,4":
|
||||||
case "iPad14,5", "iPad14,6": return "iPad Pro (12.9-inch) (6th generation)"
|
return "iPhone 16 Plus"
|
||||||
case "iPad16,5", "iPad16,6": return "iPad Pro (13-inch) (M4)"
|
case "iPhone17,1":
|
||||||
case "AppleTV5,3": return "Apple TV"
|
return "iPhone 16 Pro"
|
||||||
case "AppleTV6,2": return "Apple TV 4K"
|
case "iPhone17,2":
|
||||||
case "AudioAccessory1,1": return "HomePod"
|
return "iPhone 16 Pro Max"
|
||||||
case "AudioAccessory5,1": return "HomePod mini"
|
case "iPhone8,4":
|
||||||
case "i386", "x86_64", "arm64": return "Simulator \(mapToDevice(identifier: ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "iOS"))"
|
return "iPhone SE"
|
||||||
default: return identifier
|
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)
|
#elseif os(tvOS)
|
||||||
switch identifier {
|
switch identifier {
|
||||||
case "AppleTV5,3": return "Apple TV 4"
|
case "AppleTV5,3":
|
||||||
case "AppleTV6,2", "AppleTV11,1", "AppleTV14,1": return "Apple TV 4K"
|
return "Apple TV 4"
|
||||||
case "i386", "x86_64": return "Simulator \(mapToDevice(identifier: ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "tvOS"))"
|
case "AppleTV6,2", "AppleTV11,1", "AppleTV14,1":
|
||||||
default: return identifier
|
return "Apple TV 4K"
|
||||||
|
case "i386", "x86_64":
|
||||||
|
return "Simulator \(mapToDevice(identifier: ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"] ?? "tvOS"))"
|
||||||
|
default:
|
||||||
|
return identifier
|
||||||
}
|
}
|
||||||
#elseif os(visionOS)
|
#elseif os(visionOS)
|
||||||
switch identifier {
|
switch identifier {
|
||||||
case "RealityDevice14,1": return "Apple Vision Pro"
|
case "RealityDevice14,1":
|
||||||
default: return identifier
|
return "Apple Vision Pro"
|
||||||
|
default:
|
||||||
|
return identifier
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
return mapToDevice(identifier: identifier)
|
return mapToDevice(identifier: identifier)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,27 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
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 {
|
extension URLSession {
|
||||||
static let userAgents = [
|
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",
|
"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]
|
configuration.httpAdditionalHeaders = ["User-Agent": randomUserAgent]
|
||||||
return URLSession(configuration: configuration)
|
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 Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
extension Double {
|
extension Double {
|
||||||
func asTimeString(style: DateComponentsFormatter.UnitsStyle) -> String {
|
func asTimeString(style: DateComponentsFormatter.UnitsStyle) -> String {
|
||||||
|
|
@ -37,4 +38,9 @@ extension BinaryFloatingPoint {
|
||||||
enum TimeStringStyle {
|
enum TimeStringStyle {
|
||||||
case positional
|
case positional
|
||||||
case standard
|
case standard
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class VolumeViewModel: ObservableObject {
|
||||||
|
@Published var value: Double = 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,12 @@ import SwiftUI
|
||||||
|
|
||||||
struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
|
struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
|
||||||
@Binding var value: T
|
@Binding var value: T
|
||||||
@Binding var bufferValue: T // NEW
|
|
||||||
let inRange: ClosedRange<T>
|
let inRange: ClosedRange<T>
|
||||||
|
|
||||||
let activeFillColor: Color
|
let activeFillColor: Color
|
||||||
let fillColor: Color
|
let fillColor: Color
|
||||||
|
let textColor: Color
|
||||||
let emptyColor: Color
|
let emptyColor: Color
|
||||||
let height: CGFloat
|
let height: CGFloat
|
||||||
|
|
||||||
let onEditingChanged: (Bool) -> Void
|
let onEditingChanged: (Bool) -> Void
|
||||||
|
|
||||||
@State private var localRealProgress: T = 0
|
@State private var localRealProgress: T = 0
|
||||||
|
|
@ -28,32 +26,10 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { bounds in
|
GeometryReader { bounds in
|
||||||
ZStack {
|
ZStack {
|
||||||
VStack {
|
VStack (spacing: 8) {
|
||||||
// Base track + buffer indicator + current progress
|
|
||||||
ZStack(alignment: .center) {
|
ZStack(alignment: .center) {
|
||||||
|
|
||||||
// Entire background track
|
|
||||||
Capsule()
|
Capsule()
|
||||||
.fill(emptyColor)
|
.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()
|
Capsule()
|
||||||
.fill(isActive ? activeFillColor : fillColor)
|
.fill(isActive ? activeFillColor : fillColor)
|
||||||
.mask({
|
.mask({
|
||||||
|
|
@ -71,7 +47,6 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Time labels
|
|
||||||
HStack {
|
HStack {
|
||||||
let shouldShowHours = inRange.upperBound >= 3600
|
let shouldShowHours = inRange.upperBound >= 3600
|
||||||
Text(value.asTimeString(style: .positional, showHours: shouldShowHours))
|
Text(value.asTimeString(style: .positional, showHours: shouldShowHours))
|
||||||
|
|
@ -79,11 +54,10 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
|
||||||
Text("-" + (inRange.upperBound - value)
|
Text("-" + (inRange.upperBound - value)
|
||||||
.asTimeString(style: .positional, showHours: shouldShowHours))
|
.asTimeString(style: .positional, showHours: shouldShowHours))
|
||||||
}
|
}
|
||||||
.font(.system(size: 12))
|
.font(.system(size: 12.5))
|
||||||
.foregroundColor(isActive ? fillColor : emptyColor)
|
.foregroundColor(textColor)
|
||||||
}
|
}
|
||||||
.frame(width: isActive ? bounds.size.width * 1.04 : bounds.size.width,
|
.frame(width: isActive ? bounds.size.width * 1.04 : bounds.size.width, alignment: .center)
|
||||||
alignment: .center)
|
|
||||||
.animation(animation, value: isActive)
|
.animation(animation, value: isActive)
|
||||||
}
|
}
|
||||||
.frame(width: bounds.size.width, height: bounds.size.height, alignment: .center)
|
.frame(width: bounds.size.width, height: bounds.size.height, alignment: .center)
|
||||||
|
|
@ -95,15 +69,15 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
|
||||||
}
|
}
|
||||||
.onChanged { gesture in
|
.onChanged { gesture in
|
||||||
localTempProgress = T(gesture.translation.width / bounds.size.width)
|
localTempProgress = T(gesture.translation.width / bounds.size.width)
|
||||||
value = clampValue(getPrgValue())
|
value = max(min(getPrgValue(), inRange.upperBound), inRange.lowerBound)
|
||||||
}
|
}
|
||||||
.onEnded { _ in
|
.onEnded { _ in
|
||||||
localRealProgress = getPrgPercentage(value)
|
localRealProgress = max(min(localRealProgress + localTempProgress, 1), 0)
|
||||||
localTempProgress = 0
|
localTempProgress = 0
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.onChange(of: isActive) { newValue in
|
.onChange(of: isActive) { newValue in
|
||||||
value = clampValue(getPrgValue())
|
value = max(min(getPrgValue(), inRange.upperBound), inRange.lowerBound)
|
||||||
onEditingChanged(newValue)
|
onEditingChanged(newValue)
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
|
@ -117,26 +91,23 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
|
||||||
}
|
}
|
||||||
.frame(height: isActive ? height * 1.25 : height, alignment: .center)
|
.frame(height: isActive ? height * 1.25 : height, alignment: .center)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var animation: Animation {
|
private var animation: Animation {
|
||||||
isActive
|
if isActive {
|
||||||
? .spring()
|
return .spring()
|
||||||
: .spring(response: 0.5, dampingFraction: 0.5, blendDuration: 0.6)
|
} else {
|
||||||
|
return .spring(response: 0.5, dampingFraction: 0.5, blendDuration: 0.6)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func clampValue(_ val: T) -> T {
|
private func getPrgPercentage(_ value: T) -> T {
|
||||||
max(min(val, inRange.upperBound), inRange.lowerBound)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func getPrgPercentage(_ val: T) -> T {
|
|
||||||
let clampedValue = clampValue(val)
|
|
||||||
let range = inRange.upperBound - inRange.lowerBound
|
let range = inRange.upperBound - inRange.lowerBound
|
||||||
let pct = (clampedValue - inRange.lowerBound) / range
|
let correctedStartValue = value - inRange.lowerBound
|
||||||
return max(min(pct, 1), 0)
|
let percentage = correctedStartValue / range
|
||||||
|
return percentage
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getPrgValue() -> T {
|
private func getPrgValue() -> T {
|
||||||
((localRealProgress + localTempProgress) * (inRange.upperBound - inRange.lowerBound))
|
return ((localRealProgress + localTempProgress) * (inRange.upperBound - inRange.lowerBound)) + 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
|
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()
|
let aniListMutation = AniListMutation()
|
||||||
aniListMutation.updateAnimeProgress(animeId: self.aniListID, episodeNumber: self.episodeNumber) { result in
|
aniListMutation.updateAnimeProgress(animeId: self.aniListID, episodeNumber: self.episodeNumber) { result in
|
||||||
switch result {
|
switch result {
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,7 @@ struct ModuleAdditionSettingsView: View {
|
||||||
let _ = try await moduleManager.addModule(metadataUrl: moduleUrl)
|
let _ = try await moduleManager.addModule(metadataUrl: moduleUrl)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
isLoading = false
|
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()
|
self.presentationMode.wrappedValue.dismiss()
|
||||||
}
|
}
|
||||||
} catch {
|
} 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() {
|
init() {
|
||||||
loadBookmarks()
|
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) {
|
func removeBookmark(item: LibraryItem) {
|
||||||
|
|
|
||||||
|
|
@ -335,9 +335,11 @@ struct ContinueWatchingCell: View {
|
||||||
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(item.fullUrl)")
|
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(item.fullUrl)")
|
||||||
|
|
||||||
if totalTime > 0 {
|
if totalTime > 0 {
|
||||||
currentProgress = lastPlayedTime / totalTime
|
let ratio = lastPlayedTime / totalTime
|
||||||
|
// Clamp ratio between 0 and 1:
|
||||||
|
currentProgress = max(0, min(ratio, 1))
|
||||||
} else {
|
} 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 isLoading: Bool = true
|
||||||
@State private var currentProgress: Double = 0.0
|
@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 {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
ZStack {
|
ZStack {
|
||||||
|
|
@ -124,6 +135,10 @@ struct EpisodeCell: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func fetchEpisodeDetails() {
|
private func fetchEpisodeDetails() {
|
||||||
|
fetchAnimeEpisodeDetails()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchAnimeEpisodeDetails() {
|
||||||
guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(itemID)") else {
|
guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(itemID)") else {
|
||||||
isLoading = false
|
isLoading = false
|
||||||
return
|
return
|
||||||
|
|
@ -131,7 +146,7 @@ struct EpisodeCell: View {
|
||||||
|
|
||||||
URLSession.custom.dataTask(with: url) { data, _, error in
|
URLSession.custom.dataTask(with: url) { data, _, error in
|
||||||
if let error = error {
|
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 {
|
DispatchQueue.main.async {
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
}
|
}
|
||||||
|
|
@ -152,7 +167,7 @@ struct EpisodeCell: View {
|
||||||
let episodeDetails = episodes["\(episodeID + 1)"] as? [String: Any],
|
let episodeDetails = episodes["\(episodeID + 1)"] as? [String: Any],
|
||||||
let title = episodeDetails["title"] as? [String: String],
|
let title = episodeDetails["title"] as? [String: String],
|
||||||
let image = episodeDetails["image"] as? String else {
|
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 {
|
DispatchQueue.main.async {
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ struct MediaInfoView: View {
|
||||||
@State var airdate: String = ""
|
@State var airdate: String = ""
|
||||||
@State var episodeLinks: [EpisodeLink] = []
|
@State var episodeLinks: [EpisodeLink] = []
|
||||||
@State var itemID: Int?
|
@State var itemID: Int?
|
||||||
|
@State var tmdbID: Int?
|
||||||
|
|
||||||
@State var isLoading: Bool = true
|
@State var isLoading: Bool = true
|
||||||
@State var showFullSynopsis: Bool = false
|
@State var showFullSynopsis: Bool = false
|
||||||
|
|
@ -49,6 +50,8 @@ struct MediaInfoView: View {
|
||||||
@EnvironmentObject private var libraryManager: LibraryManager
|
@EnvironmentObject private var libraryManager: LibraryManager
|
||||||
|
|
||||||
@State private var selectedRange: Range<Int> = 0..<100
|
@State private var selectedRange: Range<Int> = 0..<100
|
||||||
|
@State private var showSettingsMenu = false
|
||||||
|
@State private var customAniListID: Int?
|
||||||
|
|
||||||
private var isGroupedBySeasons: Bool {
|
private var isGroupedBySeasons: Bool {
|
||||||
return groupedEpisodes().count > 1
|
return groupedEpisodes().count > 1
|
||||||
|
|
@ -126,6 +129,55 @@ struct MediaInfoView: View {
|
||||||
.padding(4)
|
.padding(4)
|
||||||
.background(Capsule().fill(Color.accentColor.opacity(0.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()
|
buttonRefreshTrigger.toggle()
|
||||||
|
|
||||||
if !hasFetched {
|
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()
|
fetchDetails()
|
||||||
fetchItemID(byTitle: title) { result in
|
|
||||||
switch result {
|
if let savedID = UserDefaults.standard.object(forKey: "custom_anilist_id_\(href)") as? Int {
|
||||||
case .success(let id):
|
customAniListID = savedID
|
||||||
itemID = id
|
itemID = savedID
|
||||||
case .failure(let error):
|
Logger.shared.log("Using custom AniList ID: \(savedID)", type: "Debug")
|
||||||
Logger.shared.log("Failed to fetch Item ID: \(error)")
|
} else {
|
||||||
AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch Item ID"])
|
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
|
hasFetched = true
|
||||||
AnalyticsManager.shared.sendEvent(event: "search", additionalData: ["title": title])
|
AnalyticsManager.shared.sendEvent(event: "search", additionalData: ["title": title])
|
||||||
}
|
}
|
||||||
|
|
@ -631,7 +691,7 @@ struct MediaInfoView: View {
|
||||||
Logger.shared.log("Error loading module: \(error)", type: "Error")
|
Logger.shared.log("Error loading module: \(error)", type: "Error")
|
||||||
AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch stream"])
|
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)
|
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
|
|
@ -641,11 +701,34 @@ struct MediaInfoView: View {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
let alert = UIAlertController(title: "Select Server", message: "Choose a server to play from", preferredStyle: .actionSheet)
|
let alert = UIAlertController(title: "Select Server", message: "Choose a server to play from", preferredStyle: .actionSheet)
|
||||||
|
|
||||||
for (index, stream) in streams.enumerated() {
|
var index = 0
|
||||||
let quality = "Stream \(index + 1)"
|
var streamIndex = 1
|
||||||
alert.addAction(UIAlertAction(title: quality, style: .default) { _ in
|
|
||||||
self.playStream(url: stream, fullURL: fullURL, subtitles: subtitles)
|
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))
|
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) {
|
private func fetchItemID(byTitle title: String, completion: @escaping (Result<Int, Error>) -> Void) {
|
||||||
let query = """
|
let query = """
|
||||||
query {
|
query {
|
||||||
|
|
@ -819,4 +914,33 @@ struct MediaInfoView: View {
|
||||||
}
|
}
|
||||||
}.resume()
|
}.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 {
|
struct SearchBar: View {
|
||||||
|
@State private var debounceTimer: Timer?
|
||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
var onSearchButtonClicked: () -> Void
|
var onSearchButtonClicked: () -> Void
|
||||||
|
|
||||||
|
|
@ -321,6 +322,14 @@ struct SearchBar: View {
|
||||||
.padding(.horizontal, 25)
|
.padding(.horizontal, 25)
|
||||||
.background(Color(.systemGray6))
|
.background(Color(.systemGray6))
|
||||||
.cornerRadius(8)
|
.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(
|
.overlay(
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "magnifyingglass")
|
Image(systemName: "magnifyingglass")
|
||||||
|
|
|
||||||
|
|
@ -14,13 +14,9 @@ struct SettingsViewGeneral: View {
|
||||||
@AppStorage("analyticsEnabled") private var analyticsEnabled: Bool = false
|
@AppStorage("analyticsEnabled") private var analyticsEnabled: Bool = false
|
||||||
@AppStorage("multiThreads") private var multiThreadsEnabled: Bool = false
|
@AppStorage("multiThreads") private var multiThreadsEnabled: Bool = false
|
||||||
@AppStorage("metadataProviders") private var metadataProviders: String = "AniList"
|
@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("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
|
||||||
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
|
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
|
||||||
|
|
||||||
private let customDNSProviderList = ["Cloudflare", "Google", "OpenDNS", "Quad9", "AdGuard", "CleanBrowsing", "ControlD", "Custom"]
|
|
||||||
private let metadataProvidersList = ["AniList"]
|
private let metadataProvidersList = ["AniList"]
|
||||||
@EnvironmentObject var settings: Settings
|
@EnvironmentObject var settings: Settings
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -142,14 +142,48 @@ struct SettingsViewModule: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
func showAddModuleAlert() {
|
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
|
alert.addTextField { textField in
|
||||||
textField.placeholder = "https://real.url/module.json"
|
textField.placeholder = "https://real.url/module.json"
|
||||||
}
|
}
|
||||||
|
|
||||||
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
|
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
|
||||||
alert.addAction(UIAlertAction(title: "Add", style: .default, handler: { _ in
|
alert.addAction(UIAlertAction(title: "Add", style: .default, handler: { _ in
|
||||||
if let url = alert.textFields?.first?.text {
|
if let url = alert.textFields?.first?.text, !url.isEmpty {
|
||||||
displayModuleView(url: url)
|
self.displayModuleView(url: url)
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
@ -158,16 +192,18 @@ struct SettingsViewModule: View {
|
||||||
rootViewController.present(alert, animated: true, completion: nil)
|
rootViewController.present(alert, animated: true, completion: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func displayModuleView(url: String) {
|
func displayModuleView(url: String) {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
let addModuleView = ModuleAdditionSettingsView(moduleUrl: url).environmentObject(moduleManager)
|
let addModuleView = ModuleAdditionSettingsView(moduleUrl: url)
|
||||||
|
.environmentObject(self.moduleManager)
|
||||||
let hostingController = UIHostingController(rootView: addModuleView)
|
let hostingController = UIHostingController(rootView: addModuleView)
|
||||||
|
|
||||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||||
let window = windowScene.windows.first {
|
let window = windowScene.windows.first {
|
||||||
window.rootViewController?.present(hostingController, animated: true, completion: nil)
|
window.rootViewController?.present(hostingController, animated: true, completion: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,13 @@ import SwiftUI
|
||||||
struct SettingsViewPlayer: View {
|
struct SettingsViewPlayer: View {
|
||||||
@AppStorage("externalPlayer") private var externalPlayer: String = "Sora"
|
@AppStorage("externalPlayer") private var externalPlayer: String = "Sora"
|
||||||
@AppStorage("alwaysLandscape") private var isAlwaysLandscape = false
|
@AppStorage("alwaysLandscape") private var isAlwaysLandscape = false
|
||||||
@AppStorage("hideNextButton") private var isHideNextButton = false
|
|
||||||
@AppStorage("rememberPlaySpeed") private var isRememberPlaySpeed = false
|
@AppStorage("rememberPlaySpeed") private var isRememberPlaySpeed = false
|
||||||
@AppStorage("holdSpeedPlayer") private var holdSpeedPlayer: Double = 2.0
|
@AppStorage("holdSpeedPlayer") private var holdSpeedPlayer: Double = 2.0
|
||||||
@AppStorage("skipIncrement") private var skipIncrement: Double = 10.0
|
@AppStorage("skipIncrement") private var skipIncrement: Double = 10.0
|
||||||
@AppStorage("skipIncrementHold") private var skipIncrementHold: Double = 30.0
|
@AppStorage("skipIncrementHold") private var skipIncrementHold: Double = 30.0
|
||||||
@AppStorage("holdForPauseEnabled") private var holdForPauseEnabled = false
|
@AppStorage("holdForPauseEnabled") private var holdForPauseEnabled = false
|
||||||
@AppStorage("skip85Visible") private var skip85Visible: Bool = true
|
@AppStorage("skip85Visible") private var skip85Visible: Bool = true
|
||||||
|
@AppStorage("doubleTapSeekEnabled") private var doubleTapSeekEnabled: Bool = false
|
||||||
|
|
||||||
|
|
||||||
private let mediaPlayers = ["Default", "VLC", "OutPlayer", "Infuse", "nPlayer", "Sora"]
|
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)
|
Toggle("Force Landscape", isOn: $isAlwaysLandscape)
|
||||||
.tint(.accentColor)
|
.tint(.accentColor)
|
||||||
|
|
||||||
|
|
@ -76,6 +73,10 @@ struct SettingsViewPlayer: View {
|
||||||
Spacer()
|
Spacer()
|
||||||
Stepper("\(Int(skipIncrementHold))s", value: $skipIncrementHold, in: 5...300, step: 5)
|
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)
|
Toggle("Show Skip 85s Button", isOn: $skip85Visible)
|
||||||
.tint(.accentColor)
|
.tint(.accentColor)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,18 @@ import Kingfisher
|
||||||
|
|
||||||
struct SettingsViewTrackers: View {
|
struct SettingsViewTrackers: View {
|
||||||
@AppStorage("sendPushUpdates") private var isSendPushUpdates = true
|
@AppStorage("sendPushUpdates") private var isSendPushUpdates = true
|
||||||
|
@State private var anilistStatus: String = "You are not logged in"
|
||||||
@State private var status: String = "You are not logged in"
|
@State private var isAnilistLoggedIn: Bool = false
|
||||||
@State private var isLoggedIn: Bool = false
|
@State private var anilistUsername: String = ""
|
||||||
@State private var username: String = ""
|
@State private var isAnilistLoading: Bool = false
|
||||||
@State private var isLoading: Bool = false
|
|
||||||
@State private var profileColor: Color = .accentColor
|
@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 {
|
var body: some View {
|
||||||
Form {
|
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.")) {
|
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")
|
Text("AniList.co")
|
||||||
.font(.title2)
|
.font(.title2)
|
||||||
}
|
}
|
||||||
if isLoading {
|
|
||||||
|
if isAnilistLoading {
|
||||||
ProgressView()
|
ProgressView()
|
||||||
} else {
|
} else {
|
||||||
if isLoggedIn {
|
if isAnilistLoggedIn {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
Text("Logged in as ")
|
Text("Logged in as ")
|
||||||
Text(username)
|
Text(anilistUsername)
|
||||||
.foregroundColor(profileColor)
|
.foregroundColor(profileColor)
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(status)
|
Text(anilistStatus)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if isLoggedIn {
|
|
||||||
Toggle("Sync progreses", isOn: $isSendPushUpdates)
|
if isAnilistLoggedIn {
|
||||||
|
Toggle("Sync anime progress", isOn: $isSendPushUpdates)
|
||||||
.tint(.accentColor)
|
.tint(.accentColor)
|
||||||
}
|
}
|
||||||
Button(isLoggedIn ? "Log Out from AniList.co" : "Log In with AniList.co") {
|
|
||||||
if isLoggedIn {
|
Button(isAnilistLoggedIn ? "Log Out from AniList" : "Log In with AniList") {
|
||||||
logout()
|
if isAnilistLoggedIn {
|
||||||
|
logoutAniList()
|
||||||
} else {
|
} 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)
|
.font(.body)
|
||||||
|
|
@ -68,7 +119,8 @@ struct SettingsViewTrackers: View {
|
||||||
}
|
}
|
||||||
.navigationTitle("Trackers")
|
.navigationTitle("Trackers")
|
||||||
.onAppear {
|
.onAppear {
|
||||||
updateStatus()
|
updateAniListStatus()
|
||||||
|
updateTraktStatus()
|
||||||
setupNotificationObservers()
|
setupNotificationObservers()
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.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() {
|
func setupNotificationObservers() {
|
||||||
NotificationCenter.default.addObserver(forName: AniListToken.authSuccessNotification, object: nil, queue: .main) { _ in
|
NotificationCenter.default.addObserver(forName: AniListToken.authSuccessNotification, object: nil, queue: .main) { _ in
|
||||||
self.status = "Authentication successful!"
|
self.anilistStatus = "Authentication successful!"
|
||||||
self.updateStatus()
|
self.updateAniListStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(forName: AniListToken.authFailureNotification, object: nil, queue: .main) { notification in
|
NotificationCenter.default.addObserver(forName: AniListToken.authFailureNotification, object: nil, queue: .main) { notification in
|
||||||
if let error = notification.userInfo?["error"] as? String {
|
if let error = notification.userInfo?["error"] as? String {
|
||||||
self.status = "Login failed: \(error)"
|
self.anilistStatus = "Login failed: \(error)"
|
||||||
} else {
|
} else {
|
||||||
self.status = "Login failed with unknown error"
|
self.anilistStatus = "Login failed with unknown error"
|
||||||
}
|
}
|
||||||
self.isLoggedIn = false
|
self.isAnilistLoggedIn = false
|
||||||
self.isLoading = 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() {
|
func loginTrakt() {
|
||||||
NotificationCenter.default.removeObserver(self, name: AniListToken.authSuccessNotification, object: nil)
|
traktStatus = "Starting authentication..."
|
||||||
NotificationCenter.default.removeObserver(self, name: AniListToken.authFailureNotification, object: nil)
|
isTraktLoading = true
|
||||||
|
TraktLogin.authenticate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func login() {
|
func logoutTrakt() {
|
||||||
status = "Starting authentication..."
|
removeTraktTokenFromKeychain()
|
||||||
isLoading = true
|
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()
|
AniListLogin.authenticate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func logout() {
|
func logoutAniList() {
|
||||||
removeTokenFromKeychain()
|
removeTokenFromKeychain()
|
||||||
status = "You are not logged in"
|
anilistStatus = "You are not logged in"
|
||||||
isLoggedIn = false
|
isAnilistLoggedIn = false
|
||||||
username = ""
|
anilistUsername = ""
|
||||||
profileColor = .primary
|
profileColor = .primary
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateStatus() {
|
func updateAniListStatus() {
|
||||||
if let token = getTokenFromKeychain() {
|
if let token = getTokenFromKeychain() {
|
||||||
isLoggedIn = true
|
isAnilistLoggedIn = true
|
||||||
fetchUserInfo(token: token)
|
fetchUserInfo(token: token)
|
||||||
} else {
|
} else {
|
||||||
isLoggedIn = false
|
isAnilistLoggedIn = false
|
||||||
status = "You are not logged in"
|
anilistStatus = "You are not logged in"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchUserInfo(token: String) {
|
func fetchUserInfo(token: String) {
|
||||||
isLoading = true
|
isAnilistLoading = true
|
||||||
let userInfoURL = URL(string: "https://graphql.anilist.co")!
|
let userInfoURL = URL(string: "https://graphql.anilist.co")!
|
||||||
var request = URLRequest(url: userInfoURL)
|
var request = URLRequest(url: userInfoURL)
|
||||||
request.httpMethod = "POST"
|
request.httpMethod = "POST"
|
||||||
|
|
@ -146,22 +311,22 @@ struct SettingsViewTrackers: View {
|
||||||
do {
|
do {
|
||||||
request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
|
request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
|
||||||
} catch {
|
} catch {
|
||||||
status = "Failed to serialize request"
|
anilistStatus = "Failed to serialize request"
|
||||||
Logger.shared.log("Failed to serialize request", type: "Error")
|
Logger.shared.log("Failed to serialize request", type: "Error")
|
||||||
isLoading = false
|
isAnilistLoading = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
URLSession.shared.dataTask(with: request) { data, response, error in
|
URLSession.shared.dataTask(with: request) { data, response, error in
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
isLoading = false
|
isAnilistLoading = false
|
||||||
if let error = error {
|
if let error = error {
|
||||||
status = "Error: \(error.localizedDescription)"
|
anilistStatus = "Error: \(error.localizedDescription)"
|
||||||
Logger.shared.log("Error: \(error.localizedDescription)", type: "Error")
|
Logger.shared.log("Error: \(error.localizedDescription)", type: "Error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard let data = data else {
|
guard let data = data else {
|
||||||
status = "No data received"
|
anilistStatus = "No data received"
|
||||||
Logger.shared.log("No data received", type: "Error")
|
Logger.shared.log("No data received", type: "Error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -173,15 +338,15 @@ struct SettingsViewTrackers: View {
|
||||||
let options = viewer["options"] as? [String: Any],
|
let options = viewer["options"] as? [String: Any],
|
||||||
let colorName = options["profileColor"] as? String {
|
let colorName = options["profileColor"] as? String {
|
||||||
|
|
||||||
username = name
|
anilistUsername = name
|
||||||
profileColor = colorFromName(colorName)
|
profileColor = colorFromName(colorName)
|
||||||
status = "Logged in as \(name)"
|
anilistStatus = "Logged in as \(name)"
|
||||||
} else {
|
} else {
|
||||||
status = "Unexpected response format!"
|
anilistStatus = "Unexpected response format!"
|
||||||
Logger.shared.log("Unexpected response format!", type: "Error")
|
Logger.shared.log("Unexpected response format!", type: "Error")
|
||||||
}
|
}
|
||||||
} catch {
|
} 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")
|
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 */; };
|
133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133F55BA2D33B55100E08EEA /* LibraryManager.swift */; };
|
||||||
1359ED142D76F49900C13034 /* finTopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1359ED132D76F49900C13034 /* finTopView.swift */; };
|
1359ED142D76F49900C13034 /* finTopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1359ED132D76F49900C13034 /* finTopView.swift */; };
|
||||||
135CCBE22D4D1138008B9C0E /* SettingsViewPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.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 */; };
|
138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */; };
|
||||||
138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */; };
|
138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */; };
|
||||||
139935662D468C450065CEFF /* ModuleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139935652D468C450065CEFF /* ModuleManager.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 */; };
|
13DB7CC32D7D99C0004371D3 /* SubtitleSettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB7CC22D7D99C0004371D3 /* SubtitleSettingsManager.swift */; };
|
||||||
13DB7CEC2D7DED5D004371D3 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB7CEB2D7DED5D004371D3 /* DownloadManager.swift */; };
|
13DB7CEC2D7DED5D004371D3 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DB7CEB2D7DED5D004371D3 /* DownloadManager.swift */; };
|
||||||
13DC0C462D302C7500D0F966 /* VideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13DC0C452D302C7500D0F966 /* VideoPlayer.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 */; };
|
13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD12D32D97400C1EBD7 /* CustomPlayer.swift */; };
|
||||||
13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13EA2BD32D32D97400C1EBD7 /* Double+Extension.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 */; };
|
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 */; };
|
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; };
|
||||||
1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC7A312D888BC50083984D /* MusicProgressSlider.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 */; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = "<group>"; };
|
||||||
|
|
@ -141,6 +151,7 @@
|
||||||
13103E802D589D6C000F0673 /* Tracking Services */ = {
|
13103E802D589D6C000F0673 /* Tracking Services */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
13E62FBF2DABC3A20007E259 /* Trakt */,
|
||||||
13103E812D589D77000F0673 /* AniList */,
|
13103E812D589D77000F0673 /* AniList */,
|
||||||
);
|
);
|
||||||
path = "Tracking Services";
|
path = "Tracking Services";
|
||||||
|
|
@ -250,6 +261,7 @@
|
||||||
133D7C852D2BE2640075467E /* Utils */ = {
|
133D7C852D2BE2640075467E /* Utils */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
136BBE7C2DB102BE00906B5E /* iCloudSyncManager */,
|
||||||
13DB7CEA2D7DED50004371D3 /* DownloadManager */,
|
13DB7CEA2D7DED50004371D3 /* DownloadManager */,
|
||||||
13C0E5E82D5F85DD00E7F619 /* ContinueWatching */,
|
13C0E5E82D5F85DD00E7F619 /* ContinueWatching */,
|
||||||
13103E8C2D58E037000F0673 /* SkeletonCells */,
|
13103E8C2D58E037000F0673 /* SkeletonCells */,
|
||||||
|
|
@ -268,6 +280,7 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */,
|
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */,
|
||||||
|
136BBE7F2DB1038000906B5E /* Notification+Name.swift */,
|
||||||
1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */,
|
1327FBA82D758DEA00FC6689 /* UIDevice+Model.swift */,
|
||||||
133D7C872D2BE2640075467E /* URLSession.swift */,
|
133D7C872D2BE2640075467E /* URLSession.swift */,
|
||||||
1359ED132D76F49900C13034 /* finTopView.swift */,
|
1359ED132D76F49900C13034 /* finTopView.swift */,
|
||||||
|
|
@ -308,6 +321,14 @@
|
||||||
path = LibraryView;
|
path = LibraryView;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
136BBE7C2DB102BE00906B5E /* iCloudSyncManager */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
136BBE7D2DB102D600906B5E /* iCloudSyncManager.swift */,
|
||||||
|
);
|
||||||
|
path = iCloudSyncManager;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
1384DCDF2D89BE870094797A /* Helpers */ = {
|
1384DCDF2D89BE870094797A /* Helpers */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
|
@ -395,6 +416,32 @@
|
||||||
path = MediaPlayer;
|
path = MediaPlayer;
|
||||||
sourceTree = "<group>";
|
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 */ = {
|
13EA2BD02D32D97400C1EBD7 /* CustomPlayer */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
|
@ -408,7 +455,7 @@
|
||||||
13EA2BD22D32D97400C1EBD7 /* Components */ = {
|
13EA2BD22D32D97400C1EBD7 /* Components */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
1E3F5EC72D9F16B7003F310F /* VerticalBrightnessSlider.swift */,
|
1E26E9E62DA9577900B9DC02 /* VolumeSlider.swift */,
|
||||||
13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */,
|
13EA2BD32D32D97400C1EBD7 /* Double+Extension.swift */,
|
||||||
1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */,
|
1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */,
|
||||||
);
|
);
|
||||||
|
|
@ -506,6 +553,9 @@
|
||||||
133D7C902D2BE2640075467E /* SettingsView.swift in Sources */,
|
133D7C902D2BE2640075467E /* SettingsView.swift in Sources */,
|
||||||
132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */,
|
132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */,
|
||||||
13CBEFDA2D5F7D1200D011EE /* String.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 */,
|
13DB46902D900A38008CBC03 /* URL.swift in Sources */,
|
||||||
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */,
|
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */,
|
||||||
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */,
|
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */,
|
||||||
|
|
@ -532,12 +582,14 @@
|
||||||
133D7C942D2BE2640075467E /* JSController.swift in Sources */,
|
133D7C942D2BE2640075467E /* JSController.swift in Sources */,
|
||||||
133D7C922D2BE2640075467E /* URLSession.swift in Sources */,
|
133D7C922D2BE2640075467E /* URLSession.swift in Sources */,
|
||||||
133D7C912D2BE2640075467E /* SettingsViewModule.swift in Sources */,
|
133D7C912D2BE2640075467E /* SettingsViewModule.swift in Sources */,
|
||||||
|
13E62FC22DABC5830007E259 /* Trakt-Login.swift in Sources */,
|
||||||
130217CC2D81C55E0011EFF5 /* DownloadView.swift in Sources */,
|
130217CC2D81C55E0011EFF5 /* DownloadView.swift in Sources */,
|
||||||
133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */,
|
133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */,
|
||||||
|
13E62FC42DABC58C0007E259 /* Trakt-Token.swift in Sources */,
|
||||||
133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */,
|
133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */,
|
||||||
|
13E62FC72DABFE900007E259 /* TraktPushUpdates.swift in Sources */,
|
||||||
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */,
|
133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */,
|
||||||
138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */,
|
138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */,
|
||||||
1E3F5EC82D9F16B7003F310F /* VerticalBrightnessSlider.swift in Sources */,
|
|
||||||
13DB468D2D90093A008CBC03 /* Anilist-Login.swift in Sources */,
|
13DB468D2D90093A008CBC03 /* Anilist-Login.swift in Sources */,
|
||||||
73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */,
|
73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */,
|
||||||
132AF1232D9995C300A0140B /* JSController-Details.swift in Sources */,
|
132AF1232D9995C300A0140B /* JSController-Details.swift in Sources */,
|
||||||
|
|
|
||||||