fix tons of swiftlint warnings

This commit is contained in:
Dominic Drees 2025-04-27 12:38:40 +02:00
parent d38c289fb8
commit e5a2c636b2
54 changed files with 1630 additions and 1503 deletions

View file

@ -1,19 +1,15 @@
# Directory and file filters
included:
- Plugins
- Source
- Tests
- Package.swift
excluded:
- Tests/BuiltInRulesTests/Resources
- Tests/FrameworkTests/Resources
# Enabled/disabled rules
analyzer_rules:
- unused_declaration
- unused_import
opt_in_rules:
- all
disabled_rules:
- anonymous_argument_in_multiline_closure
- async_without_await
@ -38,7 +34,6 @@ disabled_rules:
- no_grouping_extension
- no_magic_numbers
- one_declaration_per_file
- prefer_key_path # Re-enable once we are on Swift 6.
- prefer_nimble
- prefixed_toplevel_constant
- required_deinit
@ -49,6 +44,14 @@ disabled_rules:
- trailing_closure
- type_contents_order
- vertical_whitespace_between_cases
# newly added:
- multiple_closures_with_trailing_closure
- closure_body_length
- file_name
- line_length
- nesting
- legacy_objc_type
- function_body_length
# Configurations
attributes:
@ -56,30 +59,11 @@ attributes:
- "@ConfigurationElement"
- "@OptionGroup"
- "@RuleConfigurationDescriptionBuilder"
balanced_xctest_lifecycle: &unit_test_configuration
test_parent_classes:
- SwiftLintTestCase
- XCTestCase
closure_body_length:
warning: 50
error: 100
empty_xctest_method: *unit_test_configuration
file_name:
excluded:
- Exports.swift
- GeneratedTests.swift
- Macros.swift
- Reporters+Register.swift
- Rules+Register.swift
- Rules+Template.swift
- RuleConfigurationMacros.swift
- SwiftSyntax+SwiftLint.swift
- TestHelpers.swift
final_test_case: *unit_test_configuration
function_body_length: 60
identifier_name:
excluded:
- id
large_tuple: 3
number_separator:
minimum_length: 5
@ -98,12 +82,6 @@ unused_import:
# Custom rules
custom_rules:
rule_id:
included: Source/SwiftLintBuiltInRules/Rules/.+/\w+\.swift
name: Rule ID
message: Rule IDs must be all lowercase, snake case and not end with `rule`
regex: ^\s+identifier:\s*("\w+_rule"|"\S*[^a-z_]\S*")
severity: error
fatal_error:
name: Fatal Error
excluded: "Tests/*"
@ -112,9 +90,3 @@ custom_rules:
match_kinds:
- identifier
severity: error
rule_test_function:
included: Tests/SwiftLintFrameworkTests/RulesTests.swift
name: Rule Test Function
message: Rule Test Function mustn't end with `rule`
regex: func\s*test\w+(r|R)ule\(\)
severity: error

View file

@ -11,6 +11,16 @@
}
}
},
"-10s" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "-10s"
}
}
}
},
"%@ - Episode %lld" : {
"localizations" : {
"de" : {
@ -79,6 +89,16 @@
}
}
},
"+10s" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "+10s"
}
}
}
},
"25" : {
"localizations" : {
"de" : {
@ -349,6 +369,16 @@
}
}
},
"Check out some community modules here!" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Schau dir einige Community-Module hier an!"
}
}
}
},
"Clear Cache" : {
"localizations" : {
"de" : {
@ -869,6 +899,16 @@
}
}
},
"Loading %@…" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Wird geladen %@…"
}
}
}
},
"Loading Animation" : {
"localizations" : {
"de" : {
@ -1119,6 +1159,16 @@
}
}
},
"No Data Available" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Keine Informationen verfügbar"
}
}
}
},
"No data received" : {
"localizations" : {
"de" : {
@ -1164,7 +1214,7 @@
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Keine Module ausgewählt"
"value" : "Kein Modul ausgewählt"
}
}
}
@ -1209,6 +1259,16 @@
}
}
},
"Open Community Library" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Community-Bibliothek öffnen"
}
}
}
},
"Open in AniList" : {
"localizations" : {
"de" : {
@ -1599,6 +1659,16 @@
}
}
},
"Subtitle Delay: %@" : {
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Untertitel-Verzögerung: %@"
}
}
}
},
"Subtitle Settings" : {
"localizations" : {
"de" : {

View file

@ -71,7 +71,7 @@ struct SoraApp: App {
case "default_page":
if let comps = URLComponents(url: url, resolvingAgainstBaseURL: true),
let libraryURL = comps.queryItems?.first(where: { $0.name == "url" })?.value {
UserDefaults.standard.set(libraryURL, forKey: "lastCommunityURL")
UserDefaults.standard.set(true, forKey: "didReceiveDefaultPageLink")
@ -93,7 +93,7 @@ struct SoraApp: App {
}
}
}
case "module":
guard url.scheme == "sora",
url.host == "module",

View file

@ -10,15 +10,15 @@ import UIKit
class AniListLogin {
static let clientID = "19551"
static let redirectURI = "sora://anilist"
static let authorizationEndpoint = "https://anilist.co/api/v2/oauth/authorize"
static func authenticate() {
let urlString = "\(authorizationEndpoint)?client_id=\(clientID)&redirect_uri=\(redirectURI)&response_type=code"
guard let url = URL(string: urlString) else {
return
}
if UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:]) { success in
if success {

View file

@ -12,38 +12,38 @@ class AniListToken {
static let clientID = "19551"
static let clientSecret = "fk8EgkyFbXk95TbPwLYQLaiMaNIryMpDBwJsPXoX"
static let redirectURI = "sora://anilist"
static let tokenEndpoint = "https://anilist.co/api/v2/oauth/token"
static let serviceName = "me.cranci.sora.AniListToken"
static let accountName = "AniListAccessToken"
static let authSuccessNotification = Notification.Name("AniListAuthenticationSuccess")
static let authFailureNotification = Notification.Name("AniListAuthenticationFailure")
static func saveTokenToKeychain(token: String) -> Bool {
let tokenData = token.data(using: .utf8)!
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: accountName
]
SecItemDelete(deleteQuery as CFDictionary)
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: accountName,
kSecValueData as String: tokenData
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
return status == errSecSuccess
}
static func exchangeAuthorizationCodeForToken(code: String, completion: @escaping (Bool) -> Void) {
Logger.shared.log("Exchanging authorization code for access token...")
guard let url = URL(string: tokenEndpoint) else {
Logger.shared.log("Invalid token endpoint URL", type: "Error")
DispatchQueue.main.async {
@ -52,15 +52,15 @@ class AniListToken {
}
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
let bodyString = "grant_type=authorization_code&client_id=\(clientID)&client_secret=\(clientSecret)&redirect_uri=\(redirectURI)&code=\(code)"
request.httpBody = bodyString.data(using: .utf8)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
let task = URLSession.shared.dataTask(with: request) { data, _, error in
DispatchQueue.main.async {
if let error = error {
Logger.shared.log("Error: \(error.localizedDescription)", type: "Error")
@ -68,14 +68,14 @@ class AniListToken {
completion(false)
return
}
guard let data = data else {
Logger.shared.log("No data received", type: "Error")
NotificationCenter.default.post(name: authFailureNotification, object: nil, userInfo: ["error": "No data received"])
completion(false)
return
}
do {
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
if let accessToken = json["access_token"] as? String {
@ -100,7 +100,7 @@ class AniListToken {
}
}
}
task.resume()
}
}

View file

@ -10,11 +10,11 @@ import Security
class AniListMutation {
let apiURL = URL(string: "https://graphql.anilist.co")!
func getTokenFromKeychain() -> String? {
let serviceName = "me.cranci.sora.AniListToken"
let accountName = "AniListAccessToken"
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
@ -22,28 +22,28 @@ class AniListMutation {
kSecReturnData as String: kCFBooleanTrue!,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess, let tokenData = item as? Data else {
return nil
}
return String(data: tokenData, encoding: .utf8)
}
func updateAnimeProgress(animeId: Int, episodeNumber: Int, completion: @escaping (Result<Void, Error>) -> Void) {
if let sendPushUpdates = UserDefaults.standard.object(forKey: "sendPushUpdates") as? Bool,
sendPushUpdates == false {
return
}
guard let userToken = getTokenFromKeychain() else {
completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Access token not found"])))
return
}
let query = """
mutation ($mediaId: Int, $progress: Int, $status: MediaListStatus) {
SaveMediaListEntry (mediaId: $mediaId, progress: $progress, status: $status) {
@ -53,41 +53,41 @@ class AniListMutation {
}
}
"""
let variables: [String: Any] = [
"mediaId": animeId,
"progress": episodeNumber,
"status": "CURRENT"
]
let requestBody: [String: Any] = [
"query": query,
"variables": variables
]
guard let jsonData = try? JSONSerialization.data(withJSONObject: requestBody, options: []) else {
completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to serialize JSON"])))
return
}
var request = URLRequest(url: apiURL)
request.httpMethod = "POST"
request.setValue("Bearer \(userToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = jsonData
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
completion(.failure(NSError(domain: "", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Unexpected response or status code"])))
return
}
if let data = data {
do {
_ = try JSONSerialization.jsonObject(with: data, options: [])
@ -100,10 +100,10 @@ class AniListMutation {
completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "No data received"])))
}
}
task.resume()
}
func fetchMalID(animeId: Int, completion: @escaping (Result<Int, Error>) -> Void) {
let query = """
query ($id: Int) {
@ -128,7 +128,7 @@ class AniListMutation {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = jsonData
URLSession.shared.dataTask(with: request) { data, resp, error in
URLSession.shared.dataTask(with: request) { data, _, error in
if let e = error {
return completion(.failure(e))
}
@ -141,7 +141,7 @@ class AniListMutation {
completion(.success(mal))
}.resume()
}
private struct AniListMediaResponse: Decodable {
struct DataField: Decodable {
struct Media: Decodable { let idMal: Int? }

View file

@ -10,15 +10,15 @@ import UIKit
class TraktLogin {
static let clientID = "6ec81bf19deb80fdfa25652eef101576ca6aaa0dc016d36079b2de413d71c369"
static let redirectURI = "sora://trakt"
static let authorizationEndpoint = "https://trakt.tv/oauth/authorize"
static func authenticate() {
let urlString = "\(authorizationEndpoint)?client_id=\(clientID)&redirect_uri=\(redirectURI)&response_type=code"
guard let url = URL(string: urlString) else {
return
}
if UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [:]) { success in
if success {

View file

@ -12,46 +12,46 @@ class TraktToken {
static let clientID = "6ec81bf19deb80fdfa25652eef101576ca6aaa0dc016d36079b2de413d71c369"
static let clientSecret = "17cd92f71da3be9d755e2d8a6506fb3c3ecee19a247a6f0120ce2fb1f359850b"
static let redirectURI = "sora://trakt"
static let tokenEndpoint = "https://api.trakt.tv/oauth/token"
static let serviceName = "me.cranci.sora.TraktToken"
static let accessTokenKey = "TraktAccessToken"
static let refreshTokenKey = "TraktRefreshToken"
static let authSuccessNotification = Notification.Name("TraktAuthenticationSuccess")
static let authFailureNotification = Notification.Name("TraktAuthenticationFailure")
private static func saveToKeychain(key: String, data: String) -> Bool {
let tokenData = data.data(using: .utf8)!
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: key
]
SecItemDelete(deleteQuery as CFDictionary)
let addQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: serviceName,
kSecAttrAccount as String: key,
kSecValueData as String: tokenData
]
return SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess
}
static func exchangeAuthorizationCodeForToken(code: String, completion: @escaping (Bool) -> Void) {
guard let url = URL(string: tokenEndpoint) else {
Logger.shared.log("Invalid token endpoint URL", type: "Error")
handleFailure(error: "Invalid token endpoint URL", completion: completion)
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let bodyData: [String: Any] = [
"code": code,
"client_id": clientID,
@ -59,25 +59,25 @@ class TraktToken {
"redirect_uri": redirectURI,
"grant_type": "authorization_code"
]
processTokenRequest(request: request, bodyData: bodyData, completion: completion)
}
static func refreshAccessToken(completion: @escaping (Bool) -> Void) {
guard let refreshToken = getRefreshToken() else {
handleFailure(error: "No refresh token available", completion: completion)
return
}
guard let url = URL(string: tokenEndpoint) else {
handleFailure(error: "Invalid token endpoint URL", completion: completion)
return
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let bodyData: [String: Any] = [
"refresh_token": refreshToken,
"client_id": clientID,
@ -85,40 +85,40 @@ class TraktToken {
"redirect_uri": redirectURI,
"grant_type": "refresh_token"
]
processTokenRequest(request: request, bodyData: bodyData, completion: completion)
}
private static func processTokenRequest(request: URLRequest, bodyData: [String: Any], completion: @escaping (Bool) -> Void) {
var request = request
do {
request.httpBody = try JSONSerialization.data(withJSONObject: bodyData)
} catch {
handleFailure(error: "Failed to create request body", completion: completion)
return
}
let task = URLSession.shared.dataTask(with: request) { data, response, error in
let task = URLSession.shared.dataTask(with: request) { data, _, error in
DispatchQueue.main.async {
if let error = error {
handleFailure(error: error.localizedDescription, completion: completion)
return
}
guard let data = data else {
handleFailure(error: "No data received", completion: completion)
return
}
do {
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {
if let accessToken = json["access_token"] as? String,
let refreshToken = json["refresh_token"] as? String {
let accessSuccess = saveToKeychain(key: accessTokenKey, data: accessToken)
let refreshSuccess = saveToKeychain(key: refreshTokenKey, data: refreshToken)
if accessSuccess && refreshSuccess {
NotificationCenter.default.post(name: authSuccessNotification, object: nil)
completion(true)
@ -135,16 +135,16 @@ class TraktToken {
}
}
}
task.resume()
}
private static func handleFailure(error: String, completion: @escaping (Bool) -> Void) {
Logger.shared.log(error, type: "Error")
NotificationCenter.default.post(name: authFailureNotification, object: nil, userInfo: ["error": error])
completion(false)
}
private static func getRefreshToken() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
@ -152,19 +152,19 @@ class TraktToken {
kSecAttrAccount as String: refreshTokenKey,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let tokenData = result as? Data,
let token = String(data: tokenData, encoding: .utf8) else {
return nil
}
return token
}
static func getAccessToken() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
@ -172,37 +172,37 @@ class TraktToken {
kSecAttrAccount as String: accessTokenKey,
kSecReturnData as String: true
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let tokenData = result as? Data,
let token = String(data: tokenData, encoding: .utf8) else {
return nil
}
return token
}
static func validateToken(completion: @escaping (Bool) -> Void) {
guard let token = getAccessToken() else {
completion(false)
return
}
guard let url = URL(string: "https://api.trakt.tv/users/settings") else {
completion(false)
return
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("2", forHTTPHeaderField: "trakt-api-version")
request.setValue(clientID, forHTTPHeaderField: "trakt-api-key")
let task = URLSession.shared.dataTask(with: request) { _, response, _ in
DispatchQueue.main.async {
if let httpResponse = response as? HTTPURLResponse {
@ -213,10 +213,10 @@ class TraktToken {
}
}
}
task.resume()
}
static func validateAndRefreshTokenIfNeeded(completion: @escaping (Bool) -> Void) {
if getAccessToken() == nil {
if getRefreshToken() != nil {
@ -226,7 +226,7 @@ class TraktToken {
}
return
}
validateToken { isValid in
if isValid {
completion(true)
@ -239,7 +239,7 @@ class TraktToken {
}
}
}
static func checkAuthenticationStatus(completion: @escaping (Bool) -> Void) {
validateAndRefreshTokenIfNeeded(completion: completion)
}

View file

@ -10,7 +10,7 @@ import Security
class TraktMutation {
let apiURL = URL(string: "https://api.trakt.tv")!
func getTokenFromKeychain() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
@ -19,7 +19,7 @@ class TraktMutation {
kSecReturnData as String: kCFBooleanTrue!,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess,
@ -29,11 +29,11 @@ class TraktMutation {
}
return token
}
enum ExternalIDType {
case imdb(String)
case tmdb(Int)
var dictionary: [String: Any] {
switch self {
case .imdb(let id):
@ -43,21 +43,21 @@ class TraktMutation {
}
}
}
func markAsWatched(type: String, externalID: ExternalIDType, episodeNumber: Int? = nil, seasonNumber: Int? = nil, completion: @escaping (Result<Void, 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 = [
@ -67,13 +67,13 @@ class TraktMutation {
]
]
]
case "episode":
guard let episode = episodeNumber, let season = seasonNumber else {
completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing episode or season number"])))
return
}
body = [
"shows": [
[
@ -89,43 +89,43 @@ class TraktMutation {
]
]
]
default:
completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid content type"])))
return
}
var request = URLRequest(url: apiURL.appendingPathComponent(endpoint))
request.httpMethod = "POST"
request.setValue("Bearer \(userToken)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("2", forHTTPHeaderField: "trakt-api-version")
request.setValue(TraktToken.clientID, forHTTPHeaderField: "trakt-api-key")
do {
request.httpBody = try JSONSerialization.data(withJSONObject: body)
} catch {
completion(.failure(error))
return
}
let task = URLSession.shared.dataTask(with: request) { data, response, error in
let task = URLSession.shared.dataTask(with: request) { _, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
completion(.failure(NSError(domain: "", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Unexpected response or status code"])))
return
}
Logger.shared.log("Successfully updated watch status on Trakt", type: "Debug")
completion(.success(()))
}
task.resume()
}
}

View file

@ -18,36 +18,35 @@ struct AnalyticsResponse: Codable {
// MARK: - Analytics Manager
class AnalyticsManager {
static let shared = AnalyticsManager()
private let analyticsURL = URL(string: "http://151.106.3.14:47474/analytics")!
private let moduleManager = ModuleManager()
private init() {}
// MARK: - Send Analytics Data
func sendEvent(event: String, additionalData: [String: Any] = [:]) {
let defaults = UserDefaults.standard
// Ensure the key is set with a default value if missing
if defaults.object(forKey: "analyticsEnabled") == nil {
defaults.setValue(false, forKey: "analyticsEnabled")
}
let analyticsEnabled = UserDefaults.standard.bool(forKey: "analyticsEnabled")
guard analyticsEnabled else {
Logger.shared.log("Analytics is disabled, skipping event: \(event)", type: "Debug")
return
}
guard let selectedModule = getSelectedModule() else {
Logger.shared.log("No selected module found", type: "Debug")
return
}
// Prepare analytics data
var safeAdditionalData = additionalData
@ -55,7 +54,7 @@ class AnalyticsManager {
if let errorValue = additionalData["error"] as? NSError {
safeAdditionalData["error"] = errorValue.localizedDescription
}
let analyticsData: [String: Any] = [
"event": event,
"device": getDeviceModel(),
@ -64,34 +63,34 @@ class AnalyticsManager {
"module_version": selectedModule.metadata.version,
"data": safeAdditionalData
]
sendRequest(with: analyticsData)
}
// MARK: - Private Request Method
private func sendRequest(with data: [String: Any]) {
var request = URLRequest(url: analyticsURL)
request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
do {
request.httpBody = try JSONSerialization.data(withJSONObject: data, options: [])
} catch {
Logger.shared.log("Failed to encode JSON: \(error.localizedDescription)", type: "Debug")
return
}
URLSession.shared.dataTask(with: request) { (data, _, error) in
if let error = error {
Logger.shared.log("Request failed: \(error.localizedDescription)", type: "Debug")
return
}
guard let data = data else {
Logger.shared.log("No data received from server", type: "Debug")
return
}
do {
let decodedResponse = try JSONDecoder().decode(AnalyticsResponse.self, from: data)
if decodedResponse.status == "success" {
@ -104,18 +103,17 @@ class AnalyticsManager {
}
}.resume()
}
// MARK: - Get App Version
private func getAppVersion() -> String {
return Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown_version"
}
// MARK: - Get Device Model
private func getDeviceModel() -> String {
return UIDevice.modelName
}
// MARK: - Get Selected Module
private func getSelectedModule() -> ScrapingModule? {
guard let selectedModuleId = UserDefaults.standard.string(forKey: "selectedModuleId") else { return nil }

View file

@ -24,7 +24,7 @@ class ContinueWatchingManager: ObservableObject {
@objc private func handleiCloudSync() {
NotificationCenter.default.post(name: .ContinueWatchingDidUpdate, object: nil)
}
func save(item: ContinueWatchingItem) {
if item.progress >= 0.9 {
remove(item: item)
@ -43,7 +43,7 @@ class ContinueWatchingManager: ObservableObject {
userDefaultsSuite.set(data, forKey: storageKey)
}
}
func loadItems() {
if let data = userDefaultsSuite.data(forKey: storageKey),
let parsedItems = try? JSONDecoder().decode([ContinueWatchingItem].self, from: data) {
@ -52,7 +52,7 @@ class ContinueWatchingManager: ObservableObject {
items = []
}
}
func remove(item: ContinueWatchingItem) {
items.removeAll { $0.id == item.id }
if let data = try? JSONEncoder().encode(items) {

View file

@ -10,12 +10,12 @@ import UIKit
class DropManager {
static let shared = DropManager()
private init() {}
func showDrop(title: String, subtitle: String, duration: TimeInterval, icon: UIImage?) {
let position: Drop.Position = .top
let drop = Drop(
title: title,
subtitle: subtitle,

View file

@ -10,25 +10,25 @@ import JavaScriptCore
extension JSContext {
func setupConsoleLogging() {
let consoleObject = JSValue(newObjectIn: self)
let consoleLogFunction: @convention(block) (String) -> Void = { message in
Logger.shared.log(message, type: "Debug")
}
consoleObject?.setObject(consoleLogFunction, forKeyedSubscript: "log" as NSString)
let consoleErrorFunction: @convention(block) (String) -> Void = { message in
Logger.shared.log(message, type: "Error")
}
consoleObject?.setObject(consoleErrorFunction, forKeyedSubscript: "error" as NSString)
self.setObject(consoleObject, forKeyedSubscript: "console" as NSString)
let logFunction: @convention(block) (String) -> Void = { message in
Logger.shared.log("JavaScript log: \(message)", type: "Debug")
}
self.setObject(logFunction, forKeyedSubscript: "log" as NSString)
}
func setupNativeFetch() {
let fetchNativeFunction: @convention(block) (String, [String: String]?, JSValue, JSValue) -> Void = { urlString, headers, resolve, reject in
guard let url = URL(string: urlString) else {
@ -63,7 +63,7 @@ extension JSContext {
task.resume()
}
self.setObject(fetchNativeFunction, forKeyedSubscript: "fetchNative" as NSString)
let fetchDefinition = """
function fetch(url, headers) {
return new Promise(function(resolve, reject) {
@ -73,45 +73,44 @@ extension JSContext {
"""
self.evaluateScript(fetchDefinition)
}
func setupFetchV2() {
let fetchV2NativeFunction: @convention(block) (String, [String: String]?, String?, String?, ObjCBool,JSValue, JSValue) -> Void = { urlString, headers, method, body, redirect, resolve, reject in
let fetchV2NativeFunction: @convention(block) (String, [String: String]?, String?, String?, ObjCBool, JSValue, JSValue) -> Void = { urlString, headers, method, body, redirect, resolve, reject in
guard let url = URL(string: urlString) else {
Logger.shared.log("Invalid URL", type: "Error")
reject.call(withArguments: ["Invalid URL"])
return
}
let httpMethod = method ?? "GET"
var request = URLRequest(url: url)
request.httpMethod = httpMethod
Logger.shared.log("FetchV2 Request: URL=\(url), Method=\(httpMethod), Body=\(body ?? "nil")", type: "Debug")
if httpMethod == "GET", let body = body, !body.isEmpty, body != "null", body != "undefined" {
Logger.shared.log("GET request must not have a body", type: "Error")
reject.call(withArguments: ["GET request must not have a body"])
return
}
if httpMethod != "GET", let body = body, !body.isEmpty, body != "null", body != "undefined" {
request.httpBody = body.data(using: .utf8)
}
if let headers = headers {
for (key, value) in headers {
request.setValue(value, forHTTPHeaderField: key)
}
}
Logger.shared.log("Redirect value is \(redirect.boolValue)",type:"Error")
Logger.shared.log("Redirect value is \(redirect.boolValue)", type: "Error")
let task = URLSession.fetchData(allowRedirects: redirect.boolValue).downloadTask(with: request) { tempFileURL, response, error in
if let error = error {
Logger.shared.log("Network error in fetchV2NativeFunction: \(error.localizedDescription)", type: "Error")
reject.call(withArguments: [error.localizedDescription])
return
}
guard let tempFileURL = tempFileURL else {
Logger.shared.log("No data in response", type: "Error")
reject.call(withArguments: ["No data"])
@ -123,18 +122,18 @@ extension JSContext {
"headers": (response as? HTTPURLResponse)?.allHeaderFields ?? [:],
"body": ""
]
do {
let data = try Data(contentsOf: tempFileURL)
if data.count > 10_000_000 {
Logger.shared.log("Response exceeds maximum size", type: "Error")
reject.call(withArguments: ["Response exceeds maximum size"])
return
}
if let text = String(data: data, encoding: .utf8) {
responseDict["body"] = text
resolve.call(withArguments: [responseDict])
} else {
@ -142,7 +141,7 @@ extension JSContext {
Logger.shared.log("Unable to decode data to text", type: "Error")
resolve.call(withArguments: [responseDict])
}
} catch {
Logger.shared.log("Error reading downloaded file: \(error.localizedDescription)", type: "Error")
reject.call(withArguments: ["Error reading downloaded file"])
@ -150,21 +149,20 @@ extension JSContext {
}
task.resume()
}
self.setObject(fetchV2NativeFunction, forKeyedSubscript: "fetchV2Native" as NSString)
let fetchv2Definition = """
function fetchv2(url, headers = {}, method = "GET", body = null, redirect = true ) {
var processedBody = null;
if(method != "GET")
{
// Ensure body is properly serialized
processedBody = body ? JSON.stringify(body) : null
}
return new Promise(function(resolve, reject) {
fetchV2Native(url, headers, method, processedBody, redirect, function(rawText) {
const responseObj = {
@ -186,11 +184,11 @@ extension JSContext {
}, reject);
});
}
"""
self.evaluateScript(fetchv2Definition)
}
func setupBase64Functions() {
let btoaFunction: @convention(block) (String) -> String? = { data in
guard let data = data.data(using: .utf8) else {
@ -199,20 +197,20 @@ extension JSContext {
}
return data.base64EncodedString()
}
let atobFunction: @convention(block) (String) -> String? = { base64String in
guard let data = Data(base64Encoded: base64String) else {
Logger.shared.log("atob: Invalid base64 input", type: "Error")
return nil
}
return String(data: data, encoding: .utf8)
}
self.setObject(btoaFunction, forKeyedSubscript: "btoa" as NSString)
self.setObject(atobFunction, forKeyedSubscript: "atob" as NSString)
}
func setupJavaScriptEnvironment() {
setupConsoleLogging()
setupNativeFetch()

View file

@ -17,7 +17,7 @@ extension String {
let attributedString = try? NSAttributedString(data: data, options: options, documentAttributes: nil)
return attributedString?.string ?? self
}
var trimmed: String {
return self.trimmingCharacters(in: .whitespacesAndNewlines)
}

View file

@ -8,7 +8,7 @@
import UIKit
public extension UIDevice {
static let modelName: String = {
var systemInfo = utsname()
uname(&systemInfo)
@ -17,7 +17,7 @@ public extension UIDevice {
guard let value = element.value as? Int8, value != 0 else { return identifier }
return identifier + String(UnicodeScalar(UInt8(value)))
}
func mapToDevice(identifier: String) -> String { // swiftlint:disable:this cyclomatic_complexity
#if os(iOS)
switch identifier {
@ -220,7 +220,7 @@ public extension UIDevice {
}
#endif
}
return mapToDevice(identifier: identifier)
}()
}

View file

@ -7,25 +7,21 @@
import Foundation
// URL DELEGATE CLASS FOR FETCH API
class FetchDelegate: NSObject, URLSessionTaskDelegate
{
class FetchDelegate: NSObject, URLSessionTaskDelegate {
private let allowRedirects: Bool
init(allowRedirects: Bool) {
self.allowRedirects = allowRedirects
}
// This handles the redirection and prevents it.
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
if(allowRedirects)
{
if allowRedirects {
completionHandler(request) // Allow Redirect
}
else
{
} else {
completionHandler(nil) // Block Redirect
}
}
}
extension URLSession {
static let userAgents = [
@ -53,24 +49,22 @@ extension URLSession {
"Mozilla/5.0 (Android 15; Mobile; rv:128.0) Gecko/128.0 Firefox/128.0",
"Mozilla/5.0 (Android 15; Mobile; rv:127.0) Gecko/127.0 Firefox/127.0"
]
static var randomUserAgent: String = {
userAgents.randomElement() ?? userAgents[0]
}()
static let custom: URLSession = {
let configuration = URLSessionConfiguration.default
configuration.httpAdditionalHeaders = ["User-Agent": randomUserAgent]
return URLSession(configuration: configuration)
}()
// return url session that redirects based on input
static func fetchData(allowRedirects:Bool) -> URLSession
{
let delegate = FetchDelegate(allowRedirects:allowRedirects)
static func fetchData(allowRedirects: Bool) -> URLSession {
let delegate = FetchDelegate(allowRedirects: allowRedirects)
let configuration = URLSessionConfiguration.default
configuration.httpAdditionalHeaders = ["User-Agent": randomUserAgent]
return URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil)
}
}

View file

@ -12,16 +12,16 @@ class findTopViewController {
if let presented = viewController.presentedViewController {
return findViewController(presented)
}
if let navigationController = viewController as? UINavigationController {
return findViewController(navigationController.visibleViewController ?? navigationController)
}
if let tabBarController = viewController as? UITabBarController,
let selected = tabBarController.selectedViewController {
return findViewController(selected)
}
return viewController
}
}

View file

@ -8,32 +8,32 @@
import JavaScriptCore
extension JSController {
func fetchDetails(url: String, completion: @escaping ([MediaItem], [EpisodeLink]) -> Void) {
guard let url = URL(string: url) else {
completion([], [])
return
}
URLSession.custom.dataTask(with: url) { [weak self] data, _, error in
guard let self = self else { return }
if let error = error {
Logger.shared.log("Network error: \(error)",type: "Error")
Logger.shared.log("Network error: \(error)", type: "Error")
DispatchQueue.main.async { completion([], []) }
return
}
guard let data = data, let html = String(data: data, encoding: .utf8) else {
Logger.shared.log("Failed to decode HTML",type: "Error")
Logger.shared.log("Failed to decode HTML", type: "Error")
DispatchQueue.main.async { completion([], []) }
return
}
var resultItems: [MediaItem] = []
var episodeLinks: [EpisodeLink] = []
Logger.shared.log(html,type: "HTMLStrings")
Logger.shared.log(html, type: "HTMLStrings")
if let parseFunction = self.context.objectForKeyedSubscript("extractDetails"),
let results = parseFunction.call(withArguments: [html]).toArray() as? [[String: String]] {
resultItems = results.map { item in
@ -44,9 +44,9 @@ extension JSController {
)
}
} else {
Logger.shared.log("Failed to parse results",type: "Error")
Logger.shared.log("Failed to parse results", type: "Error")
}
if let fetchEpisodesFunction = self.context.objectForKeyedSubscript("extractEpisodes"),
let episodesResult = fetchEpisodesFunction.call(withArguments: [html]).toArray() as? [[String: String]] {
for episodeData in episodesResult {
@ -55,52 +55,52 @@ extension JSController {
}
}
}
DispatchQueue.main.async {
completion(resultItems, episodeLinks)
}
}.resume()
}
func fetchDetailsJS(url: String, completion: @escaping ([MediaItem], [EpisodeLink]) -> Void) {
guard let url = URL(string: url) else {
completion([], [])
return
}
if let exception = context.exception {
Logger.shared.log("JavaScript exception: \(exception)",type: "Error")
Logger.shared.log("JavaScript exception: \(exception)", type: "Error")
completion([], [])
return
}
guard let extractDetailsFunction = context.objectForKeyedSubscript("extractDetails") else {
Logger.shared.log("No JavaScript function extractDetails found",type: "Error")
Logger.shared.log("No JavaScript function extractDetails found", type: "Error")
completion([], [])
return
}
guard let extractEpisodesFunction = context.objectForKeyedSubscript("extractEpisodes") else {
Logger.shared.log("No JavaScript function extractEpisodes found",type: "Error")
Logger.shared.log("No JavaScript function extractEpisodes found", type: "Error")
completion([], [])
return
}
var resultItems: [MediaItem] = []
var episodeLinks: [EpisodeLink] = []
let dispatchGroup = DispatchGroup()
dispatchGroup.enter()
let promiseValueDetails = extractDetailsFunction.call(withArguments: [url.absoluteString])
guard let promiseDetails = promiseValueDetails else {
Logger.shared.log("extractDetails did not return a Promise",type: "Error")
Logger.shared.log("extractDetails did not return a Promise", type: "Error")
completion([], [])
return
}
let thenBlockDetails: @convention(block) (JSValue) -> Void = { result in
Logger.shared.log(result.toString(),type: "Debug")
Logger.shared.log(result.toString(), type: "Debug")
if let jsonOfDetails = result.toString(),
let dataDetails = jsonOfDetails.data(using: .utf8) {
do {
@ -113,38 +113,38 @@ extension JSController {
)
}
} else {
Logger.shared.log("Failed to parse JSON of extractDetails",type: "Error")
Logger.shared.log("Failed to parse JSON of extractDetails", type: "Error")
}
} catch {
Logger.shared.log("JSON parsing error of extract details: \(error)",type: "Error")
Logger.shared.log("JSON parsing error of extract details: \(error)", type: "Error")
}
} else {
Logger.shared.log("Result is not a string of extractDetails",type: "Error")
Logger.shared.log("Result is not a string of extractDetails", type: "Error")
}
dispatchGroup.leave()
}
let catchBlockDetails: @convention(block) (JSValue) -> Void = { error in
Logger.shared.log("Promise rejected of extractDetails: \(String(describing: error.toString()))",type: "Error")
Logger.shared.log("Promise rejected of extractDetails: \(String(describing: error.toString()))", type: "Error")
dispatchGroup.leave()
}
let thenFunctionDetails = JSValue(object: thenBlockDetails, in: context)
let catchFunctionDetails = JSValue(object: catchBlockDetails, in: context)
promiseDetails.invokeMethod("then", withArguments: [thenFunctionDetails as Any])
promiseDetails.invokeMethod("catch", withArguments: [catchFunctionDetails as Any])
dispatchGroup.enter()
let promiseValueEpisodes = extractEpisodesFunction.call(withArguments: [url.absoluteString])
guard let promiseEpisodes = promiseValueEpisodes else {
Logger.shared.log("extractEpisodes did not return a Promise",type: "Error")
Logger.shared.log("extractEpisodes did not return a Promise", type: "Error")
completion([], [])
return
}
let thenBlockEpisodes: @convention(block) (JSValue) -> Void = { result in
Logger.shared.log(result.toString(),type: "Debug")
Logger.shared.log(result.toString(), type: "Debug")
if let jsonOfEpisodes = result.toString(),
let dataEpisodes = jsonOfEpisodes.data(using: .utf8) {
do {
@ -156,28 +156,28 @@ extension JSController {
)
}
} else {
Logger.shared.log("Failed to parse JSON of extractEpisodes",type: "Error")
Logger.shared.log("Failed to parse JSON of extractEpisodes", type: "Error")
}
} catch {
Logger.shared.log("JSON parsing error of extractEpisodes: \(error)",type: "Error")
Logger.shared.log("JSON parsing error of extractEpisodes: \(error)", type: "Error")
}
} else {
Logger.shared.log("Result is not a string of extractEpisodes",type: "Error")
Logger.shared.log("Result is not a string of extractEpisodes", type: "Error")
}
dispatchGroup.leave()
}
let catchBlockEpisodes: @convention(block) (JSValue) -> Void = { error in
Logger.shared.log("Promise rejected of extractEpisodes: \(String(describing: error.toString()))",type: "Error")
Logger.shared.log("Promise rejected of extractEpisodes: \(String(describing: error.toString()))", type: "Error")
dispatchGroup.leave()
}
let thenFunctionEpisodes = JSValue(object: thenBlockEpisodes, in: context)
let catchFunctionEpisodes = JSValue(object: catchBlockEpisodes, in: context)
promiseEpisodes.invokeMethod("then", withArguments: [thenFunctionEpisodes as Any])
promiseEpisodes.invokeMethod("catch", withArguments: [catchFunctionEpisodes as Any])
dispatchGroup.notify(queue: .main) {
completion(resultItems, episodeLinks)
}

View file

@ -8,31 +8,31 @@
import JavaScriptCore
extension JSController {
func fetchSearchResults(keyword: String, module: ScrapingModule, completion: @escaping ([SearchItem]) -> Void) {
let searchUrl = module.metadata.searchBaseUrl.replacingOccurrences(of: "%s", with: keyword.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")
guard let url = URL(string: searchUrl) else {
completion([])
return
}
URLSession.custom.dataTask(with: url) { [weak self] data, _, error in
guard let self = self else { return }
if let error = error {
Logger.shared.log("Network error: \(error)",type: "Error")
Logger.shared.log("Network error: \(error)", type: "Error")
DispatchQueue.main.async { completion([]) }
return
}
guard let data = data, let html = String(data: data, encoding: .utf8) else {
Logger.shared.log("Failed to decode HTML",type: "Error")
Logger.shared.log("Failed to decode HTML", type: "Error")
DispatchQueue.main.async { completion([]) }
return
}
Logger.shared.log(html,type: "HTMLStrings")
Logger.shared.log(html, type: "HTMLStrings")
if let parseFunction = self.context.objectForKeyedSubscript("searchResults"),
let results = parseFunction.call(withArguments: [html]).toArray() as? [[String: String]] {
let resultItems = results.map { item in
@ -46,35 +46,35 @@ extension JSController {
completion(resultItems)
}
} else {
Logger.shared.log("Failed to parse results",type: "Error")
Logger.shared.log("Failed to parse results", type: "Error")
DispatchQueue.main.async { completion([]) }
}
}.resume()
}
func fetchJsSearchResults(keyword: String, module: ScrapingModule, completion: @escaping ([SearchItem]) -> Void) {
if let exception = context.exception {
Logger.shared.log("JavaScript exception: \(exception)",type: "Error")
Logger.shared.log("JavaScript exception: \(exception)", type: "Error")
completion([])
return
}
guard let searchResultsFunction = context.objectForKeyedSubscript("searchResults") else {
Logger.shared.log("No JavaScript function searchResults found",type: "Error")
Logger.shared.log("No JavaScript function searchResults found", type: "Error")
completion([])
return
}
let promiseValue = searchResultsFunction.call(withArguments: [keyword])
guard let promise = promiseValue else {
Logger.shared.log("searchResults did not return a Promise",type: "Error")
Logger.shared.log("searchResults did not return a Promise", type: "Error")
completion([])
return
}
let thenBlock: @convention(block) (JSValue) -> Void = { result in
Logger.shared.log(result.toString(),type: "HTMLStrings")
Logger.shared.log(result.toString(), type: "HTMLStrings")
if let jsonString = result.toString(),
let data = jsonString.data(using: .utf8) {
do {
@ -88,41 +88,41 @@ extension JSController {
}
return SearchItem(title: title, imageUrl: imageUrl, href: href)
}
DispatchQueue.main.async {
completion(resultItems)
}
} else {
Logger.shared.log("Failed to parse JSON",type: "Error")
Logger.shared.log("Failed to parse JSON", type: "Error")
DispatchQueue.main.async {
completion([])
}
}
} catch {
Logger.shared.log("JSON parsing error: \(error)",type: "Error")
Logger.shared.log("JSON parsing error: \(error)", type: "Error")
DispatchQueue.main.async {
completion([])
}
}
} else {
Logger.shared.log("Result is not a string",type: "Error")
Logger.shared.log("Result is not a string", type: "Error")
DispatchQueue.main.async {
completion([])
}
}
}
let catchBlock: @convention(block) (JSValue) -> Void = { error in
Logger.shared.log("Promise rejected: \(String(describing: error.toString()))",type: "Error")
Logger.shared.log("Promise rejected: \(String(describing: error.toString()))", type: "Error")
DispatchQueue.main.async {
completion([])
}
}
let thenFunction = JSValue(object: thenBlock, in: context)
let catchFunction = JSValue(object: catchBlock, in: context)
promise.invokeMethod("then", withArguments: [thenFunction as Any])
promise.invokeMethod("catch", withArguments: [catchFunction as Any])
}

View file

@ -8,37 +8,37 @@
import JavaScriptCore
extension JSController {
func fetchStreamUrl(episodeUrl: String, softsub: Bool = false, module: ScrapingModule, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) {
guard let url = URL(string: episodeUrl) else {
completion((nil, nil))
return
}
URLSession.custom.dataTask(with: url) { [weak self] data, _, error in
guard let self = self else { return }
if let error = error {
Logger.shared.log("Network error: \(error)", type: "Error")
DispatchQueue.main.async { completion((nil, nil)) }
return
}
guard let data = data, let html = String(data: data, encoding: .utf8) else {
Logger.shared.log("Failed to decode HTML", type: "Error")
DispatchQueue.main.async { completion((nil, nil)) }
return
}
Logger.shared.log(html, type: "HTMLStrings")
if let parseFunction = self.context.objectForKeyedSubscript("extractStreamUrl"),
let resultString = parseFunction.call(withArguments: [html]).toString() {
if let data = resultString.data(using: .utf8) {
do {
if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
var streamUrls: [String]? = nil
var subtitleUrls: [String]? = nil
var streamUrls: [String]?
var subtitleUrls: [String]?
if let streamsArray = json["streams"] as? [String] {
streamUrls = streamsArray
Logger.shared.log("Found \(streamsArray.count) streams", type: "Stream")
@ -46,7 +46,7 @@ extension JSController {
streamUrls = [streamUrl]
Logger.shared.log("Found single stream", type: "Stream")
}
if let subsArray = json["subtitles"] as? [String] {
subtitleUrls = subsArray
Logger.shared.log("Found \(subsArray.count) subtitle tracks", type: "Stream")
@ -54,14 +54,14 @@ extension JSController {
subtitleUrls = [subtitleUrl]
Logger.shared.log("Found single subtitle track", type: "Stream")
}
Logger.shared.log("Starting stream with \(streamUrls?.count ?? 0) sources and \(subtitleUrls?.count ?? 0) subtitles", type: "Stream")
DispatchQueue.main.async {
completion((streamUrls, subtitleUrls))
}
return
}
if let streamsArray = try? JSONSerialization.jsonObject(with: data, options: []) as? [String] {
Logger.shared.log("Starting multi-stream with \(streamsArray.count) sources", type: "Stream")
DispatchQueue.main.async { completion((streamsArray, nil)) }
@ -69,7 +69,7 @@ extension JSController {
}
}
}
Logger.shared.log("Starting stream from: \(resultString)", type: "Stream")
DispatchQueue.main.async { completion(([resultString], nil)) }
} else {
@ -78,37 +78,37 @@ extension JSController {
}
}.resume()
}
func fetchStreamUrlJS(episodeUrl: String, softsub: Bool = false, module: ScrapingModule, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) {
if let exception = context.exception {
Logger.shared.log("JavaScript exception: \(exception)", type: "Error")
completion((nil, nil))
return
}
guard let extractStreamUrlFunction = context.objectForKeyedSubscript("extractStreamUrl") else {
Logger.shared.log("No JavaScript function extractStreamUrl found", type: "Error")
completion((nil, nil))
return
}
let promiseValue = extractStreamUrlFunction.call(withArguments: [episodeUrl])
guard let promise = promiseValue else {
Logger.shared.log("extractStreamUrl did not return a Promise", type: "Error")
completion((nil, nil))
return
}
let thenBlock: @convention(block) (JSValue) -> Void = { [weak self] result in
guard self != nil else { return }
if let jsonString = result.toString(),
let data = jsonString.data(using: .utf8) {
do {
if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
var streamUrls: [String]? = nil
var subtitleUrls: [String]? = nil
var streamUrls: [String]?
var subtitleUrls: [String]?
if let streamsArray = json["streams"] as? [String] {
streamUrls = streamsArray
Logger.shared.log("Found \(streamsArray.count) streams", type: "Stream")
@ -116,7 +116,7 @@ extension JSController {
streamUrls = [streamUrl]
Logger.shared.log("Found single stream", type: "Stream")
}
if let subsArray = json["subtitles"] as? [String] {
subtitleUrls = subsArray
Logger.shared.log("Found \(subsArray.count) subtitle tracks", type: "Stream")
@ -124,14 +124,14 @@ extension JSController {
subtitleUrls = [subtitleUrl]
Logger.shared.log("Found single subtitle track", type: "Stream")
}
Logger.shared.log("Starting stream with \(streamUrls?.count ?? 0) sources and \(subtitleUrls?.count ?? 0) subtitles", type: "Stream")
DispatchQueue.main.async {
completion((streamUrls, subtitleUrls))
}
return
}
if let streamsArray = try? JSONSerialization.jsonObject(with: data, options: []) as? [String] {
Logger.shared.log("Starting multi-stream with \(streamsArray.count) sources", type: "Stream")
DispatchQueue.main.async { completion((streamsArray, nil)) }
@ -139,75 +139,75 @@ extension JSController {
}
}
}
let streamUrl = result.toString()
Logger.shared.log("Starting stream from: \(streamUrl ?? "nil")", type: "Stream")
DispatchQueue.main.async {
completion((streamUrl != nil ? [streamUrl!] : nil, nil))
}
}
let catchBlock: @convention(block) (JSValue) -> Void = { error in
Logger.shared.log("Promise rejected: \(String(describing: error.toString()))", type: "Error")
DispatchQueue.main.async {
completion((nil, nil))
}
}
let thenFunction = JSValue(object: thenBlock, in: context)
let catchFunction = JSValue(object: catchBlock, in: context)
promise.invokeMethod("then", withArguments: [thenFunction as Any])
promise.invokeMethod("catch", withArguments: [catchFunction as Any])
}
func fetchStreamUrlJSSecond(episodeUrl: String, softsub: Bool = false, module: ScrapingModule, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) {
let url = URL(string: episodeUrl)!
let task = URLSession.custom.dataTask(with: url) { [weak self] data, response, error in
let task = URLSession.custom.dataTask(with: url) { [weak self] data, _, error in
guard let self = self else { return }
if let error = error {
Logger.shared.log("URLSession error: \(error.localizedDescription)", type: "Error")
DispatchQueue.main.async { completion((nil, nil)) }
return
}
guard let data = data, let htmlString = String(data: data, encoding: .utf8) else {
Logger.shared.log("Failed to fetch HTML data", type: "Error")
DispatchQueue.main.async { completion((nil, nil)) }
return
}
DispatchQueue.main.async {
if let exception = self.context.exception {
Logger.shared.log("JavaScript exception: \(exception)", type: "Error")
completion((nil, nil))
return
}
guard let extractStreamUrlFunction = self.context.objectForKeyedSubscript("extractStreamUrl") else {
Logger.shared.log("No JavaScript function extractStreamUrl found", type: "Error")
completion((nil, nil))
return
}
let promiseValue = extractStreamUrlFunction.call(withArguments: [htmlString])
guard let promise = promiseValue else {
Logger.shared.log("extractStreamUrl did not return a Promise", type: "Error")
completion((nil, nil))
return
}
let thenBlock: @convention(block) (JSValue) -> Void = { [weak self] result in
guard self != nil else { return }
if let jsonString = result.toString(),
let data = jsonString.data(using: .utf8) {
do {
if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
var streamUrls: [String]? = nil
var subtitleUrls: [String]? = nil
var streamUrls: [String]?
var subtitleUrls: [String]?
if let streamsArray = json["streams"] as? [String] {
streamUrls = streamsArray
Logger.shared.log("Found \(streamsArray.count) streams", type: "Stream")
@ -215,7 +215,7 @@ extension JSController {
streamUrls = [streamUrl]
Logger.shared.log("Found single stream", type: "Stream")
}
if let subsArray = json["subtitles"] as? [String] {
subtitleUrls = subsArray
Logger.shared.log("Found \(subsArray.count) subtitle tracks", type: "Stream")
@ -223,14 +223,14 @@ extension JSController {
subtitleUrls = [subtitleUrl]
Logger.shared.log("Found single subtitle track", type: "Stream")
}
Logger.shared.log("Starting stream with \(streamUrls?.count ?? 0) sources and \(subtitleUrls?.count ?? 0) subtitles", type: "Stream")
DispatchQueue.main.async {
completion((streamUrls, subtitleUrls))
}
return
}
if let streamsArray = try? JSONSerialization.jsonObject(with: data, options: []) as? [String] {
Logger.shared.log("Starting multi-stream with \(streamsArray.count) sources", type: "Stream")
DispatchQueue.main.async { completion((streamsArray, nil)) }
@ -238,24 +238,24 @@ extension JSController {
}
}
}
let streamUrl = result.toString()
Logger.shared.log("Starting stream from: \(streamUrl ?? "nil")", type: "Stream")
DispatchQueue.main.async {
completion((streamUrl != nil ? [streamUrl!] : nil, nil))
}
}
let catchBlock: @convention(block) (JSValue) -> Void = { error in
Logger.shared.log("Promise rejected: \(String(describing: error.toString()))", type: "Error")
DispatchQueue.main.async {
completion((nil, nil))
}
}
let thenFunction = JSValue(object: thenBlock, in: self.context)
let catchFunction = JSValue(object: catchBlock, in: self.context)
promise.invokeMethod("then", withArguments: [thenFunction as Any])
promise.invokeMethod("catch", withArguments: [catchFunction as Any])
}

View file

@ -9,16 +9,16 @@ import JavaScriptCore
class JSController: ObservableObject {
var context: JSContext
init() {
self.context = JSContext()
setupContext()
}
func setupContext() {
context.setupJavaScriptEnvironment()
}
func loadScript(_ script: String) {
context = JSContext()
setupContext()

View file

@ -9,51 +9,50 @@ import Foundation
class Logger {
static let shared = Logger()
struct LogEntry {
let message: String
let type: String
let timestamp: Date
}
private var logs: [LogEntry] = []
private let logFileURL: URL
private let logFilterViewModel = LogFilterViewModel.shared
private init() {
let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
logFileURL = documentDirectory.appendingPathComponent("logs.txt")
}
func log(_ message: String, type: String = "General") {
guard logFilterViewModel.isFilterEnabled(for: type) else { return }
let entry = LogEntry(message: message, type: type, timestamp: Date())
logs.append(entry)
saveLogToFile(entry)
debugLog(entry)
}
func getLogs() -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "dd-MM HH:mm:ss"
return logs.map { "[\(dateFormatter.string(from: $0.timestamp))] [\($0.type)] \($0.message)" }
.joined(separator: "\n----\n")
}
func clearLogs() {
logs.removeAll()
try? FileManager.default.removeItem(at: logFileURL)
}
private func saveLogToFile(_ log: LogEntry) {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "dd-MM HH:mm:ss"
let logString = "[\(dateFormatter.string(from: log.timestamp))] [\(log.type)] \(log.message)\n---\n"
if let data = logString.data(using: .utf8) {
if FileManager.default.fileExists(atPath: logFileURL.path) {
if let handle = try? FileHandle(forWritingTo: logFileURL) {
@ -66,7 +65,7 @@ class Logger {
}
}
}
/// Prints log messages to the Xcode console only in DEBUG mode
private func debugLog(_ entry: LogEntry) {
#if DEBUG

View file

@ -26,7 +26,7 @@ extension BinaryFloatingPoint {
let hours = totalSeconds / 3600
let minutes = (totalSeconds % 3600) / 60
let seconds = totalSeconds % 60
if showHours || hours > 0 {
return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
} else {
@ -62,12 +62,12 @@ struct AniSkipResponse: Decodable {
struct Result: Decodable {
struct Interval: Decodable {
let startTime: Double
let endTime: Double
let endTime: Double
}
let interval: Interval
let skipType: String
}
let found: Bool
let results: [Result]
let found: Bool
let results: [Result]
let statusCode: Int
}

View file

@ -22,11 +22,11 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
let outroSegments: [ClosedRange<T>]
let introColor: Color
let outroColor: Color
@State private var localRealProgress: T = 0
@State private var localTempProgress: T = 0
@GestureState private var isActive: Bool = false
var body: some View {
GeometryReader { bounds in
ZStack {
@ -44,7 +44,7 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
Spacer()
}
}
// Outro Segments
ForEach(outroSegments, id: \.self) { segment in
HStack(spacing: 0) {
@ -56,7 +56,7 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
Spacer()
}
}
Capsule()
.fill(emptyColor)
}
@ -78,7 +78,7 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
}
})
}
HStack {
let shouldShowHours = inRange.upperBound >= 3600
Text(value.asTimeString(style: .positional, showHours: shouldShowHours))
@ -138,7 +138,7 @@ struct MusicProgressSlider<T: BinaryFloatingPoint>: View {
let percentage = correctedStartValue / range
return percentage
}
private func getPrgValue() -> T {
return ((localRealProgress + localTempProgress) * (inRange.upperBound - inRange.lowerBound)) + inRange.lowerBound
}

View file

@ -42,7 +42,7 @@ struct VolumeSlider<T: BinaryFloatingPoint>: View {
}
}
}
Image(systemName: getIconName)
.font(.system(size: 20, weight: .bold, design: .rounded))
.frame(width: 30)
@ -100,7 +100,7 @@ struct VolumeSlider<T: BinaryFloatingPoint>: View {
let lowThreshold: T = 0.2
let midThreshold: T = 0.35
let highThreshold: T = 0.7
switch p {
case muteThreshold:
return "speaker.slash.fill"
@ -117,7 +117,7 @@ struct VolumeSlider<T: BinaryFloatingPoint>: View {
private func handleIconTap() {
let currentProgress = localRealProgress + localTempProgress
withAnimation {
if currentProgress <= 0 {
value = lastVolumeValue

File diff suppressed because it is too large Load diff

View file

@ -18,9 +18,9 @@ struct SubtitleSettings: Codable {
class SubtitleSettingsManager {
static let shared = SubtitleSettingsManager()
private let userDefaultsKey = "SubtitleSettings"
var settings: SubtitleSettings {
get {
if let data = UserDefaults.standard.data(forKey: userDefaultsKey),
@ -35,7 +35,7 @@ class SubtitleSettingsManager {
}
}
}
func update(_ updateBlock: (inout SubtitleSettings) -> Void) {
var currentSettings = settings
updateBlock(&currentSettings)

View file

@ -17,23 +17,23 @@ struct SubtitleCue: Identifiable {
class VTTSubtitlesLoader: ObservableObject {
@Published var cues: [SubtitleCue] = []
enum SubtitleFormat {
case vtt
case srt
case unknown
}
func load(from urlString: String) {
guard let url = URL(string: urlString) else { return }
let format = determineSubtitleFormat(from: url)
URLSession.shared.dataTask(with: url) { data, _, error in
guard let data = data,
let content = String(data: data, encoding: .utf8),
error == nil else { return }
DispatchQueue.main.async {
switch format {
case .vtt:
@ -50,7 +50,7 @@ class VTTSubtitlesLoader: ObservableObject {
}
}.resume()
}
private func determineSubtitleFormat(from url: URL) -> SubtitleFormat {
let fileExtension = url.pathExtension.lowercased()
switch fileExtension {
@ -62,31 +62,31 @@ class VTTSubtitlesLoader: ObservableObject {
return .unknown
}
}
private func parseVTT(content: String) -> [SubtitleCue] {
var cues: [SubtitleCue] = []
let lines = content.components(separatedBy: .newlines)
var index = 0
while index < lines.count {
let line = lines[index].trimmingCharacters(in: .whitespaces)
if line.isEmpty || line == "WEBVTT" {
index += 1
continue
}
if !line.contains("-->") {
index += 1
if index >= lines.count { break }
}
let timeLine = lines[index]
let times = timeLine.components(separatedBy: "-->")
if times.count < 2 {
index += 1
continue
}
let startTime = parseTimecode(times[0].trimmingCharacters(in: .whitespaces))
let adjustedStartTime = max(startTime - 0.5, 0)
let endTime = parseTimecode(times[1].trimmingCharacters(in: .whitespaces))
@ -101,39 +101,39 @@ class VTTSubtitlesLoader: ObservableObject {
}
return cues
}
private func parseSRT(content: String) -> [SubtitleCue] {
var cues: [SubtitleCue] = []
let normalizedContent = content.replacingOccurrences(of: "\r\n", with: "\n")
.replacingOccurrences(of: "\r", with: "\n")
let blocks = normalizedContent.components(separatedBy: "\n\n")
for block in blocks {
let lines = block.components(separatedBy: "\n").filter { !$0.isEmpty }
guard lines.count >= 2 else { continue }
let timeLine = lines[1]
let times = timeLine.components(separatedBy: "-->")
guard times.count >= 2 else { continue }
let startTime = parseSRTTimecode(times[0].trimmingCharacters(in: .whitespaces))
let adjustedStartTime = max(startTime - 0.5, 0)
let endTime = parseSRTTimecode(times[1].trimmingCharacters(in: .whitespaces))
let adjustedEndTime = max(endTime - 0.5, 0)
var textLines = [String]()
if lines.count > 2 {
textLines = Array(lines[2...])
}
let text = textLines.joined(separator: "\n")
cues.append(SubtitleCue(startTime: adjustedStartTime, endTime: adjustedEndTime, text: text))
}
return cues
}
private func parseTimecode(_ timeString: String) -> Double {
let parts = timeString.components(separatedBy: ":")
var seconds = 0.0
@ -149,11 +149,11 @@ class VTTSubtitlesLoader: ObservableObject {
}
return seconds
}
private func parseSRTTimecode(_ timeString: String) -> Double {
let parts = timeString.components(separatedBy: ":")
guard parts.count == 3 else { return 0 }
let secondsParts = parts[2].components(separatedBy: ",")
guard secondsParts.count == 2,
let hours = Double(parts[0]),
@ -162,7 +162,7 @@ class VTTSubtitlesLoader: ObservableObject {
let milliseconds = Double(secondsParts[1]) else {
return 0
}
return hours * 3600 + minutes * 60 + seconds + milliseconds / 1000
}
}

View file

@ -10,13 +10,13 @@ import AVKit
class NormalPlayer: AVPlayerViewController {
private var originalRate: Float = 1.0
private var holdGesture: UILongPressGestureRecognizer?
override func viewDidLoad() {
super.viewDidLoad()
setupHoldGesture()
setupAudioSession()
}
private func setupHoldGesture() {
holdGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleHoldGesture(_:)))
holdGesture?.minimumPressDuration = 0.5
@ -24,7 +24,7 @@ class NormalPlayer: AVPlayerViewController {
view.addGestureRecognizer(holdGesture)
}
}
@objc private func handleHoldGesture(_ gesture: UILongPressGestureRecognizer) {
switch gesture.state {
case .began:
@ -35,24 +35,24 @@ class NormalPlayer: AVPlayerViewController {
break
}
}
private func beginHoldSpeed() {
guard let player = player else { return }
originalRate = player.rate
let holdSpeed = UserDefaults.standard.float(forKey: "holdSpeedPlayer")
player.rate = holdSpeed > 0 ? holdSpeed : 2.0
}
private func endHoldSpeed() {
player?.rate = originalRate
}
func setupAudioSession() {
do {
let audioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.playback, mode: .moviePlayback, options: .mixWithOthers)
try audioSession.setActive(true)
try audioSession.overrideOutputAudioPort(.speaker)
} catch {
Logger.shared.log("Didn't set up AVAudioSession: \(error)", type: "Debug")

View file

@ -19,41 +19,41 @@ class VideoPlayerViewController: UIViewController {
var fullUrl: String = ""
var subtitles: String = ""
var aniListID: Int = 0
var episodeNumber: Int = 0
var episodeImageUrl: String = ""
var mediaTitle: String = ""
init(module: ScrapingModule, continueWatchingManager: ContinueWatchingManager) {
self.module = module
self.continueWatchingManager = continueWatchingManager
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
guard let streamUrl = streamUrl, let url = URL(string: streamUrl) else {
return
}
var request = URLRequest(url: url)
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin")
request.addValue(URLSession.randomUserAgent, forHTTPHeaderField: "User-Agent")
let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]])
let playerItem = AVPlayerItem(asset: asset)
player = AVPlayer(playerItem: playerItem)
playerViewController = NormalPlayer()
playerViewController?.player = player
if let playerViewController = playerViewController {
addChild(playerViewController)
playerViewController.view.frame = view.bounds
@ -61,7 +61,7 @@ class VideoPlayerViewController: UIViewController {
view.addSubview(playerViewController.view)
playerViewController.didMove(toParent: self)
}
addPeriodicTimeObserver(fullURL: fullUrl)
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(fullUrl)")
if lastPlayedTime > 0 {
@ -73,13 +73,13 @@ class VideoPlayerViewController: UIViewController {
self.player?.play()
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
player?.play()
setInitialPlayerRate()
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
if let playbackSpeed = player?.rate {
@ -91,17 +91,17 @@ class VideoPlayerViewController: UIViewController {
self.timeObserverToken = nil
}
}
private func setInitialPlayerRate() {
if UserDefaults.standard.bool(forKey: "rememberPlaySpeed") {
let lastPlayedSpeed = UserDefaults.standard.float(forKey: "lastPlaybackSpeed")
player?.rate = lastPlayedSpeed > 0 ? lastPlayedSpeed : 1.0
}
}
func addPeriodicTimeObserver(fullURL: String) {
guard let player = self.player else { return }
let interval = CMTime(seconds: 1.0, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
timeObserverToken = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
guard let self = self,
@ -109,16 +109,16 @@ class VideoPlayerViewController: UIViewController {
currentItem.duration.seconds.isFinite else {
return
}
let currentTime = time.seconds
let duration = currentItem.duration.seconds
UserDefaults.standard.set(currentTime, forKey: "lastPlayedTime_\(fullURL)")
UserDefaults.standard.set(duration, forKey: "totalTime_\(fullURL)")
if let streamUrl = self.streamUrl {
let progress = min(max(currentTime / duration, 0), 1.0)
let item = ContinueWatchingItem(
id: UUID(),
imageUrl: self.episodeImageUrl,
@ -133,9 +133,9 @@ class VideoPlayerViewController: UIViewController {
)
continueWatchingManager.save(item: item)
}
let remainingPercentage = (duration - currentTime) / duration
if remainingPercentage < 0.1 && self.aniListID != 0 {
let aniListMutation = AniListMutation()
aniListMutation.updateAnimeProgress(animeId: self.aniListID, episodeNumber: self.episodeNumber) { result in
@ -149,7 +149,7 @@ class VideoPlayerViewController: UIViewController {
}
}
}
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
if UserDefaults.standard.bool(forKey: "alwaysLandscape") {
return .landscape
@ -157,15 +157,15 @@ class VideoPlayerViewController: UIViewController {
return .all
}
}
override var prefersHomeIndicatorAutoHidden: Bool {
return true
}
override var prefersStatusBarHidden: Bool {
return true
}
deinit {
player?.pause()
if let timeObserverToken = timeObserverToken {

View file

@ -71,7 +71,14 @@ struct WebView: UIViewRepresentable {
func makeUIView(context: Context) -> WKWebView {
let cfg = WKWebViewConfiguration()
cfg.preferences.javaScriptEnabled = true
if #available(iOS 14.0, *) {
let webpagePreferences = WKWebpagePreferences()
webpagePreferences.allowsContentJavaScript = true
cfg.defaultWebpagePreferences = webpagePreferences
} else {
cfg.preferences.javaScriptEnabled = true
}
let wv = WKWebView(frame: .zero, configuration: cfg)
wv.navigationDelegate = context.coordinator
return wv
@ -89,11 +96,9 @@ struct WebView: UIViewRepresentable {
func webView(_ webView: WKWebView,
decidePolicyFor action: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void)
{
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if let url = action.request.url,
url.scheme == "sora", url.host == "module"
{
url.scheme == "sora", url.host == "module" {
onCustom(url)
decisionHandler(.cancel)
} else {

View file

@ -11,12 +11,12 @@ import Kingfisher
struct ModuleAdditionSettingsView: View {
@Environment(\.presentationMode) var presentationMode
@EnvironmentObject var moduleManager: ModuleManager
@State private var moduleMetadata: ModuleMetadata?
@State private var isLoading = false
@State private var errorMessage: String?
var moduleUrl: String
var body: some View {
VStack {
ScrollView {
@ -31,15 +31,15 @@ struct ModuleAdditionSettingsView: View {
.clipShape(Circle())
.shadow(radius: 5)
.transition(.scale)
Text(metadata.sourceName)
.font(.system(size: 28, weight: .bold))
.multilineTextAlignment(.center)
}
.padding(.top)
Divider()
HStack(spacing: 15) {
KFImage(URL(string: metadata.author.icon))
.resizable()
@ -47,7 +47,7 @@ struct ModuleAdditionSettingsView: View {
.frame(width: 60, height: 60)
.clipShape(Circle())
.shadow(radius: 3)
VStack(alignment: .leading, spacing: 4) {
Text(metadata.author.name)
.font(.headline)
@ -58,9 +58,9 @@ struct ModuleAdditionSettingsView: View {
Spacer()
}
.padding(.horizontal)
Divider()
VStack(alignment: .leading, spacing: 12) {
InfoRow(title: "Version", value: metadata.version)
InfoRow(title: "Language", value: metadata.language)
@ -79,9 +79,9 @@ struct ModuleAdditionSettingsView: View {
}
.padding(.horizontal)
}
Divider()
} else if isLoading {
VStack(spacing: 20) {
ProgressView()
@ -105,9 +105,9 @@ struct ModuleAdditionSettingsView: View {
}
}
}
Spacer()
VStack {
Button(action: addModule) {
HStack {
@ -126,7 +126,7 @@ struct ModuleAdditionSettingsView: View {
}
.disabled(isLoading)
.opacity(isLoading ? 0.6 : 1)
Button(action: {
self.presentationMode.wrappedValue.dismiss()
}) {
@ -140,11 +140,11 @@ struct ModuleAdditionSettingsView: View {
.navigationTitle("Add Module")
.onAppear(perform: fetchModuleMetadata)
}
private func fetchModuleMetadata() {
isLoading = true
errorMessage = nil
Task {
guard let url = URL(string: moduleUrl) else {
await MainActor.run {
@ -169,15 +169,15 @@ struct ModuleAdditionSettingsView: View {
}
}
}
private func addModule() {
isLoading = true
Task {
do {
let _ = try await moduleManager.addModule(metadataUrl: moduleUrl)
_ = try await moduleManager.addModule(metadataUrl: moduleUrl)
await MainActor.run {
isLoading = false
DropManager.shared.showDrop(title: "Module Added", subtitle: "Click it to select it.", duration: 2.0, icon: UIImage(systemName:"gear.badge.checkmark"))
DropManager.shared.showDrop(title: "Module Added", subtitle: "Click it to select it.", duration: 2.0, icon: UIImage(systemName: "gear.badge.checkmark"))
self.presentationMode.wrappedValue.dismiss()
}
} catch {
@ -198,7 +198,7 @@ struct ModuleAdditionSettingsView: View {
struct InfoRow: View {
let title: String
let value: String
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(title)

View file

@ -9,13 +9,13 @@ import Foundation
class ModuleManager: ObservableObject {
@Published var modules: [ScrapingModule] = []
private let fileManager = FileManager.default
private let modulesFileName = "modules.json"
init() {
let url = getModulesFilePath()
if (!FileManager.default.fileExists(atPath: url.path)) {
if !FileManager.default.fileExists(atPath: url.path) {
do {
try "[]".write(to: url, atomically: true, encoding: .utf8)
Logger.shared.log("Created empty modules file", type: "Info")
@ -26,27 +26,27 @@ class ModuleManager: ObservableObject {
loadModules()
NotificationCenter.default.addObserver(self, selector: #selector(handleModulesSyncCompleted), name: .modulesSyncDidComplete, object: nil)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
@objc private func handleModulesSyncCompleted() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
let url = self.getModulesFilePath()
guard FileManager.default.fileExists(atPath: url.path) else {
Logger.shared.log("No modules file found after sync", type: "Error")
self.modules = []
return
}
do {
let data = try Data(contentsOf: url)
let decodedModules = try JSONDecoder().decode([ScrapingModule].self, from: data)
self.modules = decodedModules
Task {
await self.checkJSModuleFiles()
}
@ -57,18 +57,18 @@ class ModuleManager: ObservableObject {
}
}
}
private func getDocumentsDirectory() -> URL {
fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0]
}
private func getModulesFilePath() -> URL {
getDocumentsDirectory().appendingPathComponent(modulesFileName)
}
func loadModules() {
let url = getModulesFilePath()
guard FileManager.default.fileExists(atPath: url.path) else {
Logger.shared.log("Modules file does not exist, creating empty one", type: "Info")
do {
@ -80,13 +80,13 @@ class ModuleManager: ObservableObject {
}
return
}
do {
let data = try Data(contentsOf: url)
do {
let decodedModules = try JSONDecoder().decode([ScrapingModule].self, from: data)
modules = decodedModules
Task {
await checkJSModuleFiles()
}
@ -100,11 +100,11 @@ class ModuleManager: ObservableObject {
modules = []
}
}
func checkJSModuleFiles() async {
Logger.shared.log("Checking JS module files...", type: "Info")
var missingCount = 0
for module in modules {
let localUrl = getDocumentsDirectory().appendingPathComponent(module.localPath)
if !fileManager.fileExists(atPath: localUrl.path) {
@ -114,15 +114,15 @@ class ModuleManager: ObservableObject {
Logger.shared.log("Invalid script URL for module: \(module.metadata.sourceName)", type: "Error")
continue
}
Logger.shared.log("Downloading missing JS file for: \(module.metadata.sourceName)", type: "Info")
let (scriptData, _) = try await URLSession.custom.data(from: scriptUrl)
guard let jsContent = String(data: scriptData, encoding: .utf8) else {
Logger.shared.log("Invalid script encoding for module: \(module.metadata.sourceName)", type: "Error")
continue
}
try jsContent.write(to: localUrl, atomically: true, encoding: .utf8)
Logger.shared.log("Successfully downloaded JS file for module: \(module.metadata.sourceName)")
} catch {
@ -130,14 +130,14 @@ class ModuleManager: ObservableObject {
}
}
}
if missingCount > 0 {
Logger.shared.log("Downloaded \(missingCount) missing module JS files", type: "Info")
} else {
Logger.shared.log("All module JS files are present", type: "Info")
}
}
private func saveModules() {
DispatchQueue.main.async {
let url = self.getModulesFilePath()
@ -146,80 +146,79 @@ class ModuleManager: ObservableObject {
}
}
func addModule(metadataUrl: String) async throws -> ScrapingModule {
guard let url = URL(string: metadataUrl) else {
throw NSError(domain: "Invalid metadata URL", code: -1)
}
if modules.contains(where: { $0.metadataUrl == metadataUrl }) {
throw NSError(domain: "Module already exists", code: -1)
}
let (metadataData, _) = try await URLSession.custom.data(from: url)
let metadata = try JSONDecoder().decode(ModuleMetadata.self, from: metadataData)
guard let scriptUrl = URL(string: metadata.scriptUrl) else {
throw NSError(domain: "Invalid script URL", code: -1)
}
let (scriptData, _) = try await URLSession.custom.data(from: scriptUrl)
guard let jsContent = String(data: scriptData, encoding: .utf8) else {
throw NSError(domain: "Invalid script encoding", code: -1)
}
let fileName = "\(UUID().uuidString).js"
let localUrl = getDocumentsDirectory().appendingPathComponent(fileName)
try jsContent.write(to: localUrl, atomically: true, encoding: .utf8)
let module = ScrapingModule(
metadata: metadata,
localPath: fileName,
metadataUrl: metadataUrl
)
await MainActor.run {
self.modules.append(module)
self.saveModules()
Logger.shared.log("Added module: \(module.metadata.sourceName)")
}
return module
}
func deleteModule(_ module: ScrapingModule) {
let localUrl = getDocumentsDirectory().appendingPathComponent(module.localPath)
try? fileManager.removeItem(at: localUrl)
modules.removeAll { $0.id == module.id }
saveModules()
Logger.shared.log("Deleted module: \(module.metadata.sourceName)")
}
func getModuleContent(_ module: ScrapingModule) throws -> String {
let localUrl = getDocumentsDirectory().appendingPathComponent(module.localPath)
return try String(contentsOf: localUrl, encoding: .utf8)
}
func refreshModules() async {
for (index, module) in modules.enumerated() {
do {
let (metadataData, _) = try await URLSession.custom.data(from: URL(string: module.metadataUrl)!)
let newMetadata = try JSONDecoder().decode(ModuleMetadata.self, from: metadataData)
if newMetadata.version != module.metadata.version {
guard let scriptUrl = URL(string: newMetadata.scriptUrl) else {
throw NSError(domain: "Invalid script URL", code: -1)
}
let (scriptData, _) = try await URLSession.custom.data(from: scriptUrl)
guard let jsContent = String(data: scriptData, encoding: .utf8) else {
throw NSError(domain: "Invalid script encoding", code: -1)
}
let localUrl = getDocumentsDirectory().appendingPathComponent(module.localPath)
try jsContent.write(to: localUrl, atomically: true, encoding: .utf8)
let updatedModule = ScrapingModule(
id: module.id,
metadata: newMetadata,
@ -227,12 +226,12 @@ class ModuleManager: ObservableObject {
metadataUrl: module.metadataUrl,
isActive: module.isActive
)
await MainActor.run {
self.modules[index] = updatedModule
self.saveModules()
}
Logger.shared.log("Updated module: \(module.metadata.sourceName) to version \(newMetadata.version)")
}
} catch {

View file

@ -37,7 +37,7 @@ struct ScrapingModule: Codable, Identifiable, Hashable {
let localPath: String
let metadataUrl: String
var isActive: Bool
init(id: UUID = UUID(), metadata: ModuleMetadata, localPath: String, metadataUrl: String, isActive: Bool = false) {
self.id = id
self.metadata = metadata
@ -45,11 +45,11 @@ struct ScrapingModule: Codable, Identifiable, Hashable {
self.metadataUrl = metadataUrl
self.isActive = isActive
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: ScrapingModule, rhs: ScrapingModule) -> Bool {
lhs.id == rhs.id
}

View file

@ -76,14 +76,14 @@ class ProfileStore: ObservableObject {
}
public func deleteCurrentProfile() {
if (profiles.count == 1) { return }
if profiles.count == 1 { return }
if let suite = UserDefaults(suiteName: currentProfile.id.uuidString) {
for key in suite.dictionaryRepresentation().keys {
suite.removeObject(forKey: key)
}
}
profiles.removeAll { $0.id == currentProfile.id }
if let firstProfile = profiles.first {

View file

@ -24,7 +24,7 @@ import UIKit
// TODO: tests
class iCloudSyncManager {
static let shared = iCloudSyncManager()
private let defaultsToSync: [String] = [
"externalPlayer",
"alwaysLandscape",
@ -51,19 +51,19 @@ class iCloudSyncManager {
"profilesData",
"currentProfileID"
]
private let modulesFileName = "modules.json"
private var ubiquityContainerURL: URL? {
FileManager.default.url(forUbiquityContainerIdentifier: nil)?.appendingPathComponent("Documents")
}
private init() {
setupSync()
NotificationCenter.default.addObserver(self, selector: #selector(willEnterBackground), name: UIApplication.willResignActiveNotification, object: nil)
}
private func setupSync() {
NSUbiquitousKeyValueStore.default.synchronize()
syncFromiCloud()
@ -71,12 +71,12 @@ class iCloudSyncManager {
NotificationCenter.default.addObserver(self, selector: #selector(iCloudDidChangeExternally), name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: NSUbiquitousKeyValueStore.default)
NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange), name: UserDefaults.didChangeNotification, object: nil)
}
@objc private func willEnterBackground() {
syncToiCloud()
syncModulesToiCloud()
}
private func allProgressKeys() -> [String] {
let allKeys = UserDefaults.standard.dictionaryRepresentation().keys
let progressPrefixes = ["lastPlayedTime_", "totalTime_"]
@ -84,7 +84,7 @@ class iCloudSyncManager {
progressPrefixes.contains { prefix in key.hasPrefix(prefix) }
}
}
private func allKeysToSync() -> [String] {
var keys = Set(defaultsToSync + allProgressKeys())
let userDefaults = UserDefaults.standard
@ -97,34 +97,34 @@ class iCloudSyncManager {
}
return Array(keys)
}
private func syncFromiCloud() {
let iCloud = NSUbiquitousKeyValueStore.default
let defaults = UserDefaults.standard
for key in allKeysToSync() {
if let value = iCloud.object(forKey: key) {
defaults.set(value, forKey: key)
}
}
defaults.synchronize()
NotificationCenter.default.post(name: .iCloudSyncDidComplete, object: nil)
}
private func syncToiCloud() {
let iCloud = NSUbiquitousKeyValueStore.default
let defaults = UserDefaults.standard
for key in allKeysToSync() {
if let value = defaults.object(forKey: key) {
iCloud.set(value, forKey: key)
}
}
iCloud.synchronize()
}
@objc private func iCloudDidChangeExternally(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let reason = userInfo[NSUbiquitousKeyValueStoreChangeReasonKey] as? Int else {
@ -136,11 +136,11 @@ class iCloudSyncManager {
syncModulesFromiCloud()
}
}
@objc private func userDefaultsDidChange(_ notification: Notification) {
syncToiCloud()
}
func syncModulesToiCloud() {
DispatchQueue.global(qos: .background).async {
guard let iCloudURL = self.ubiquityContainerURL else { return }
@ -148,7 +148,7 @@ class iCloudSyncManager {
let iCloudModulesURL = iCloudURL.appendingPathComponent(self.modulesFileName)
do {
guard FileManager.default.fileExists(atPath: localModulesURL.path) else { return }
let shouldCopy: Bool
if FileManager.default.fileExists(atPath: iCloudModulesURL.path) {
let localData = try Data(contentsOf: localModulesURL)
@ -157,7 +157,7 @@ class iCloudSyncManager {
} else {
shouldCopy = true
}
if shouldCopy {
if FileManager.default.fileExists(atPath: iCloudModulesURL.path) {
try FileManager.default.removeItem(at: iCloudModulesURL)
@ -169,20 +169,20 @@ class iCloudSyncManager {
}
}
}
func syncModulesFromiCloud() {
guard let iCloudURL = self.ubiquityContainerURL else {
Logger.shared.log("iCloud container not available", type: "Error")
return
}
let localModulesURL = self.getLocalModulesFileURL()
let iCloudModulesURL = iCloudURL.appendingPathComponent(self.modulesFileName)
do {
if !FileManager.default.fileExists(atPath: iCloudModulesURL.path) {
Logger.shared.log("No modules file found in iCloud", type: "Info")
if FileManager.default.fileExists(atPath: localModulesURL.path) {
Logger.shared.log("Copying local modules file to iCloud", type: "Info")
try FileManager.default.copyItem(at: localModulesURL, to: iCloudModulesURL)
@ -191,16 +191,16 @@ class iCloudSyncManager {
let emptyModules: [ScrapingModule] = []
let emptyData = try JSONEncoder().encode(emptyModules)
try emptyData.write(to: iCloudModulesURL)
try emptyData.write(to: localModulesURL)
DispatchQueue.main.async {
NotificationCenter.default.post(name: .modulesSyncDidComplete, object: nil)
}
}
return
}
let shouldCopy: Bool
if FileManager.default.fileExists(atPath: localModulesURL.path) {
let localData = try Data(contentsOf: localModulesURL)
@ -209,14 +209,14 @@ class iCloudSyncManager {
} else {
shouldCopy = true
}
if shouldCopy {
Logger.shared.log("Syncing modules from iCloud", type: "Info")
if FileManager.default.fileExists(atPath: localModulesURL.path) {
try FileManager.default.removeItem(at: localModulesURL)
}
try FileManager.default.copyItem(at: iCloudModulesURL, to: localModulesURL)
DispatchQueue.main.async {
NotificationCenter.default.post(name: .modulesSyncDidComplete, object: nil)
}
@ -225,7 +225,7 @@ class iCloudSyncManager {
Logger.shared.log("iCloud modules sync error: \(error)", type: "Error")
}
}
private func getLocalModulesFileURL() -> URL {
let docs = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
return docs.appendingPathComponent(modulesFileName)

View file

@ -18,11 +18,11 @@ struct DownloadItem: Identifiable {
class DownloadViewModel: ObservableObject {
@Published var downloads: [DownloadItem] = []
init() {
NotificationCenter.default.addObserver(self, selector: #selector(updateStatus(_:)), name: .DownloadManagerStatusUpdate, object: nil)
}
@objc func updateStatus(_ notification: Notification) {
guard let info = notification.userInfo,
let title = info["title"] as? String,
@ -30,7 +30,7 @@ class DownloadViewModel: ObservableObject {
let type = info["type"] as? String,
let status = info["status"] as? String,
let progress = info["progress"] as? Double else { return }
if let index = downloads.firstIndex(where: { $0.title == title && $0.episode == episode }) {
downloads[index] = DownloadItem(title: title, episode: episode, type: type, progress: progress, status: status)
} else {
@ -42,7 +42,7 @@ class DownloadViewModel: ObservableObject {
struct DownloadView: View {
@StateObject var viewModel = DownloadViewModel()
var body: some View {
NavigationView {
List(viewModel.downloads) { download in
@ -51,15 +51,15 @@ struct DownloadView: View {
.resizable()
.frame(width: 30, height: 30)
.foregroundColor(.accentColor)
VStack(alignment: .leading, spacing: 4) {
Text("\(download.title) - Episode \(download.episode)")
.font(.headline)
ProgressView(value: download.progress)
.progressViewStyle(LinearProgressViewStyle(tint: .accentColor))
.frame(height: 8)
Text(download.status)
.font(.subheadline)
.foregroundColor(.secondary)
@ -72,7 +72,7 @@ struct DownloadView: View {
}
.navigationViewStyle(StackNavigationViewStyle())
}
func iconName(for download: DownloadItem) -> String {
if download.type == "hls" {
return download.status.lowercased().contains("converting") ? "arrow.triangle.2.circlepath.circle.fill" : "checkmark.circle.fill"

View file

@ -15,7 +15,7 @@ struct LibraryItem: Codable, Identifiable {
let moduleId: String
let moduleName: String
let dateAdded: Date
init(title: String, imageUrl: String, href: String, moduleId: String, moduleName: String) {
self.id = UUID()
self.title = title
@ -31,7 +31,7 @@ class LibraryManager: ObservableObject {
var userDefaultsSuite = UserDefaults.standard
@Published var bookmarks: [LibraryItem] = []
private let bookmarksKey = "bookmarkedItems"
init() {
NotificationCenter.default.addObserver(self, selector: #selector(handleiCloudSync), name: .iCloudSyncDidComplete, object: nil)
}
@ -46,16 +46,16 @@ class LibraryManager: ObservableObject {
self.loadBookmarks()
}
}
func removeBookmark(item: LibraryItem) {
if let index = bookmarks.firstIndex(where: { $0.id == item.id }) {
bookmarks.remove(at: index)
Logger.shared.log("Removed series \(item.id) from bookmarks.",type: "Debug")
Logger.shared.log("Removed series \(item.id) from bookmarks.", type: "Debug")
saveBookmarks()
}
}
private func loadBookmarks() {
guard let data = userDefaultsSuite.data(forKey: bookmarksKey) else {
Logger.shared.log("No bookmarks data found in UserDefaults.", type: "Debug")
@ -70,7 +70,7 @@ class LibraryManager: ObservableObject {
bookmarks = []
}
}
private func saveBookmarks() {
do {
let encoded = try JSONEncoder().encode(bookmarks)
@ -79,11 +79,11 @@ class LibraryManager: ObservableObject {
Logger.shared.log("Failed to save bookmarks: \(error)", type: "Error")
}
}
func isBookmarked(href: String, moduleName: String) -> Bool {
bookmarks.contains { $0.href == href }
}
func toggleBookmark(title: String, imageUrl: String, href: String, moduleId: String, moduleName: String) {
if let index = bookmarks.firstIndex(where: { $0.href == href }) {
bookmarks.remove(at: index)

View file

@ -17,12 +17,12 @@ struct LibraryView: View {
@AppStorage("hideEmptySections") private var hideEmptySections: Bool?
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
@Environment(\.verticalSizeClass) var verticalSizeClass
@State private var selectedBookmark: LibraryItem? = nil
@State private var selectedBookmark: LibraryItem?
@State private var isDetailActive: Bool = false
@State private var continueWatchingItems: [ContinueWatchingItem] = []
@State private var isLandscape: Bool = UIDevice.current.orientation.isLandscape
@State private var showProfileSettings = false
@ -30,7 +30,7 @@ struct LibraryView: View {
private let columns = [
GridItem(.adaptive(minimum: 150), spacing: 12)
]
private var columnsCount: Int {
if UIDevice.current.userInterfaceIdiom == .pad {
let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height
@ -39,7 +39,7 @@ struct LibraryView: View {
return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait
}
}
private var cellWidth: CGFloat {
let keyWindow = UIApplication.shared.connectedScenes
.compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow }) }
@ -50,12 +50,12 @@ struct LibraryView: View {
let availableWidth = safeWidth - totalSpacing
return availableWidth / CGFloat(columnsCount)
}
var body: some View {
NavigationView {
ScrollView {
let columnsCount = determineColumns()
VStack(alignment: .leading, spacing: 12) {
if hideEmptySections != true || !continueWatchingManager.items.isEmpty {
@ -235,7 +235,7 @@ struct LibraryView: View {
updateOrientation()
}
}
private func markContinueWatchingItemAsWatched(item: ContinueWatchingItem) {
let key = "lastPlayedTime_\(item.fullUrl)"
let totalKey = "totalTime_\(item.fullUrl)"
@ -243,17 +243,17 @@ struct LibraryView: View {
UserDefaults.standard.set(99999999.0, forKey: totalKey)
continueWatchingManager.remove(item: item)
}
private func removeContinueWatchingItem(item: ContinueWatchingItem) {
continueWatchingManager.remove(item: item)
}
private func updateOrientation() {
DispatchQueue.main.async {
isLandscape = UIDevice.current.orientation.isLandscape
}
}
private func determineColumns() -> Int {
if UIDevice.current.userInterfaceIdiom == .pad {
return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
@ -267,7 +267,7 @@ struct ContinueWatchingSection: View {
@Binding var items: [ContinueWatchingItem]
var markAsWatched: (ContinueWatchingItem) -> Void
var removeItem: (ContinueWatchingItem) -> Void
var body: some View {
VStack(alignment: .leading) {
ScrollView(.horizontal, showsIndicators: false) {
@ -289,13 +289,13 @@ struct ContinueWatchingSection: View {
struct ContinueWatchingCell: View {
@EnvironmentObject private var continueWatchingManager: ContinueWatchingManager
let item: ContinueWatchingItem
var markAsWatched: () -> Void
var removeItem: () -> Void
@State private var currentProgress: Double = 0.0
var body: some View {
Button(action: {
if UserDefaults.standard.string(forKey: "externalPlayer") == "Default" {
@ -308,7 +308,7 @@ struct ContinueWatchingCell: View {
videoPlayerViewController.subtitles = item.subtitles ?? ""
videoPlayerViewController.aniListID = item.aniListID ?? 0
videoPlayerViewController.modalPresentationStyle = .fullScreen
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootVC = windowScene.windows.first?.rootViewController {
findTopViewController.findViewController(rootVC).present(videoPlayerViewController, animated: true, completion: nil)
@ -327,7 +327,7 @@ struct ContinueWatchingCell: View {
episodeImageUrl: item.imageUrl
)
customMediaPlayer.modalPresentationStyle = .fullScreen
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootVC = windowScene.windows.first?.rootViewController {
findTopViewController.findViewController(rootVC).present(customMediaPlayer, animated: true, completion: nil)
@ -364,7 +364,7 @@ struct ContinueWatchingCell: View {
.fill(Color.black.opacity(0.3))
.blur(radius: 3)
.frame(height: 30)
ProgressView(value: currentProgress)
.progressViewStyle(LinearProgressViewStyle(tint: .white))
.padding(.horizontal, 8)
@ -372,13 +372,13 @@ struct ContinueWatchingCell: View {
},
alignment: .bottom
)
VStack(alignment: .leading) {
Text("Episode \(item.episodeNumber)")
.font(.caption)
.lineLimit(1)
.foregroundColor(.secondary)
Text(item.mediaTitle)
.font(.caption)
.lineLimit(2)
@ -403,11 +403,11 @@ struct ContinueWatchingCell: View {
updateProgress()
}
}
private func updateProgress() {
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(item.fullUrl)")
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(item.fullUrl)")
if totalTime > 0 {
let ratio = lastPlayedTime / totalTime
// Clamp ratio between 0 and 1:

View file

@ -9,21 +9,21 @@ import SwiftUI
struct CircularProgressBar: View {
var progress: Double
var body: some View {
ZStack {
Circle()
.stroke(lineWidth: 5.0)
.opacity(0.3)
.foregroundColor(Color.accentColor)
Circle()
.trim(from: 0.0, to: CGFloat(min(progress, 1.0)))
.stroke(style: StrokeStyle(lineWidth: 5.0, lineCap: .round, lineJoin: .round))
.foregroundColor(Color.accentColor)
.rotationEffect(Angle(degrees: 270.0))
.animation(.linear, value: progress)
if progress >= 0.9 {
Image(systemName: "checkmark")
.font(.system(size: 12))

View file

@ -20,25 +20,25 @@ struct EpisodeCell: View {
let episodeID: Int
let progress: Double
let itemID: Int
let onTap: (String) -> Void
let onMarkAllPrevious: () -> Void
@State private var episodeTitle: String = ""
@State private var episodeImageUrl: String = ""
@State private var isLoading: Bool = true
@State private var currentProgress: Double = 0.0
@Environment(\.colorScheme) private var colorScheme
@AppStorage("selectedAppearance") private var selectedAppearance: Appearance = .system
var defaultBannerImage: String {
let isLightMode = selectedAppearance == .light || (selectedAppearance == .system && colorScheme == .light)
return isLightMode
? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png"
: "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner2.png"
}
init(episodeIndex: Int, episode: String, episodeID: Int, progress: Double,
itemID: Int, onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void) {
self.episodeIndex = episodeIndex
@ -49,7 +49,7 @@ struct EpisodeCell: View {
self.onTap = onTap
self.onMarkAllPrevious = onMarkAllPrevious
}
var body: some View {
HStack {
ZStack {
@ -58,13 +58,13 @@ struct EpisodeCell: View {
.aspectRatio(16/9, contentMode: .fill)
.frame(width: 100, height: 56)
.cornerRadius(8)
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
}
}
VStack(alignment: .leading) {
Text("Episode \(episodeID + 1)")
.font(.system(size: 15))
@ -74,9 +74,9 @@ struct EpisodeCell: View {
.foregroundColor(.secondary)
}
}
Spacer()
CircularProgressBar(progress: currentProgress)
.frame(width: 40, height: 40)
}
@ -87,13 +87,13 @@ struct EpisodeCell: View {
Label("Mark as Watched", systemImage: "checkmark.circle")
}
}
if progress != 0 {
Button(action: resetProgress) {
Label("Reset Progress", systemImage: "arrow.counterclockwise")
}
}
if episodeIndex > 0 {
Button(action: onMarkAllPrevious) {
Label("Mark All Previous Watched", systemImage: "checkmark.circle.fill")
@ -112,7 +112,7 @@ struct EpisodeCell: View {
onTap(imageUrl)
}
}
private func markAsWatched() {
let userDefaults = UserDefaults.standard
let totalTime = 1000.0
@ -123,7 +123,7 @@ struct EpisodeCell: View {
self.updateProgress()
}
}
private func resetProgress() {
let userDefaults = UserDefaults.standard
userDefaults.set(0.0, forKey: "lastPlayedTime_\(episode)")
@ -132,36 +132,36 @@ struct EpisodeCell: View {
self.updateProgress()
}
}
private func updateProgress() {
let userDefaults = UserDefaults.standard
let lastPlayedTime = userDefaults.double(forKey: "lastPlayedTime_\(episode)")
let totalTime = userDefaults.double(forKey: "totalTime_\(episode)")
currentProgress = totalTime > 0 ? min(lastPlayedTime / totalTime, 1.0) : 0
}
private func fetchEpisodeDetails() {
fetchAnimeEpisodeDetails()
}
private func fetchAnimeEpisodeDetails() {
guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(itemID)") else {
isLoading = false
return
}
URLSession.custom.dataTask(with: url) { data, _, error in
if let error = error {
Logger.shared.log("Failed to fetch anime episode details: \(error)", type: "Error")
DispatchQueue.main.async { self.isLoading = false }
return
}
guard let data = data else {
DispatchQueue.main.async { self.isLoading = false }
return
}
do {
let jsonObject = try JSONSerialization.jsonObject(with: data, options: [])
guard let json = jsonObject as? [String: Any],
@ -173,7 +173,7 @@ struct EpisodeCell: View {
DispatchQueue.main.async { self.isLoading = false }
return
}
DispatchQueue.main.async {
self.isLoading = false
if UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil

File diff suppressed because it is too large Load diff

View file

@ -15,18 +15,17 @@ struct SearchItem: Identifiable {
let href: String
}
struct SearchView: View {
@AppStorage("hideEmptySections") private var hideEmptySections: Bool?
@AppStorage("selectedModuleId") private var selectedModuleId: String?
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
@StateObject private var jsController = JSController()
@EnvironmentObject private var moduleManager: ModuleManager
@EnvironmentObject private var profileStore: ProfileStore
@Environment(\.verticalSizeClass) var verticalSizeClass
@State private var searchItems: [SearchItem] = []
@State private var selectedSearchItem: SearchItem?
@State private var isSearching = false
@ -40,7 +39,7 @@ struct SearchView: View {
guard let id = selectedModuleId else { return nil }
return moduleManager.modules.first { $0.id.uuidString == id }
}
private var loadingMessages: [String] = [
"Searching the depths...",
"Looking for results...",
@ -48,7 +47,7 @@ struct SearchView: View {
"Please wait...",
"Almost there..."
]
private var columnsCount: Int {
if UIDevice.current.userInterfaceIdiom == .pad {
let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height
@ -57,7 +56,7 @@ struct SearchView: View {
return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait
}
}
private var cellWidth: CGFloat {
let keyWindow = UIApplication.shared.connectedScenes
.compactMap { ($0 as? UIWindowScene)?.windows.first(where: { $0.isKeyWindow }) }
@ -68,7 +67,7 @@ struct SearchView: View {
let availableWidth = safeWidth - totalSpacing
return availableWidth / CGFloat(columnsCount)
}
var body: some View {
NavigationView {
ScrollView {
@ -80,7 +79,7 @@ struct SearchView: View {
.padding(.trailing, searchText.isEmpty ? 16 : 0)
.disabled(selectedModule == nil)
.padding(.top)
if !searchText.isEmpty {
Button("Cancel") {
searchText = ""
@ -90,7 +89,7 @@ struct SearchView: View {
.padding(.top)
}
}
if !(hideEmptySections ?? false) && selectedModule == nil {
VStack(spacing: 8) {
Image(systemName: "questionmark.app")
@ -106,7 +105,7 @@ struct SearchView: View {
.frame(maxWidth: .infinity)
.background(Color(.systemBackground))
}
if !searchText.isEmpty {
if isSearching {
LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 16), count: columnsCount), spacing: 16) {
@ -162,7 +161,7 @@ struct SearchView: View {
}
}
}
NavigationLink(
destination: SettingsViewProfile(),
isActive: $showProfileSettings,
@ -263,7 +262,7 @@ struct SearchView: View {
}
}
}
private func performSearch() {
Logger.shared.log("Searching for: \(searchText)", type: "General")
guard !searchText.isEmpty, let module = selectedModule else {
@ -271,11 +270,11 @@ struct SearchView: View {
hasNoResults = false
return
}
isSearching = true
hasNoResults = false
searchItems = []
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
Task {
do {
@ -302,13 +301,13 @@ struct SearchView: View {
}
}
}
private func updateOrientation() {
DispatchQueue.main.async {
isLandscape = UIDevice.current.orientation.isLandscape
}
}
private func determineColumns() -> Int {
if UIDevice.current.userInterfaceIdiom == .pad {
return isLandscape ? mediaColumnsLandscape : mediaColumnsPortrait
@ -316,22 +315,22 @@ struct SearchView: View {
return verticalSizeClass == .compact ? mediaColumnsLandscape : mediaColumnsPortrait
}
}
private func cleanLanguageName(_ language: String?) -> String {
guard let language = language else { return "Unknown" }
let cleaned = language.replacingOccurrences(
of: "\\s*\\([^\\)]*\\)",
with: "",
options: .regularExpression
).trimmingCharacters(in: .whitespaces)
return cleaned.isEmpty ? "Unknown" : cleaned
}
private func getModulesByLanguage() -> [String: [ScrapingModule]] {
var result = [String: [ScrapingModule]]()
for module in moduleManager.modules {
let language = cleanLanguageName(module.metadata.language)
if result[language] == nil {
@ -340,14 +339,14 @@ struct SearchView: View {
result[language]?.append(module)
}
}
return result
}
private func getModuleLanguageGroups() -> [String] {
return getModulesByLanguage().keys.sorted()
}
private func getModulesForLanguage(_ language: String) -> [ScrapingModule] {
return getModulesByLanguage()[language] ?? []
}
@ -357,7 +356,7 @@ struct SearchBar: View {
@State private var debounceTimer: Timer?
@Binding var text: String
var onSearchButtonClicked: () -> Void
var body: some View {
HStack {
TextField("Search...", text: $text, onCommit: onSearchButtonClicked)
@ -365,7 +364,7 @@ struct SearchBar: View {
.padding(.horizontal, 25)
.background(Color(.systemGray6))
.cornerRadius(8)
.onChange(of: text){newValue in
.onChange(of: text) {_ in
debounceTimer?.invalidate()
// Start a new timer to wait before performing the action
debounceTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
@ -379,7 +378,7 @@ struct SearchBar: View {
.foregroundColor(.secondary)
.frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
.padding(.leading, 8)
if !text.isEmpty {
Button(action: {
self.text = ""

View file

@ -11,14 +11,14 @@ struct SettingsViewData: View {
@State private var showEraseAppDataAlert = false
@State private var showRemoveDocumentsAlert = false
@State private var showSizeAlert = false
var body: some View {
Form {
Section(header: Text("App storage"), footer: Text("The caches used by Sora are stored images that help load content faster\n\nThe App Data should never be erased if you dont know what that will cause.\n\nClearing the documents folder will remove all the modules and downloads")) {
Button(action: clearCache) {
Text("Clear Cache")
}
Button(action: {
showEraseAppDataAlert = true
}) {
@ -34,7 +34,7 @@ struct SettingsViewData: View {
secondaryButton: .cancel()
)
}
Button(action: {
showRemoveDocumentsAlert = true
}) {
@ -55,7 +55,7 @@ struct SettingsViewData: View {
.navigationTitle("App Data")
.navigationViewStyle(StackNavigationViewStyle())
}
func eraseAppData() {
if let domain = Bundle.main.bundleIdentifier {
UserDefaults.standard.removePersistentDomain(forName: domain)
@ -64,10 +64,10 @@ struct SettingsViewData: View {
exit(0)
}
}
func clearCache() {
let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
do {
if let cacheURL = cacheURL {
let filePaths = try FileManager.default.contentsOfDirectory(at: cacheURL, includingPropertiesForKeys: nil, options: [])
@ -80,7 +80,7 @@ struct SettingsViewData: View {
Logger.shared.log("Failed to clear cache.", type: "Error")
}
}
func removeAllFilesInDocuments() {
let fileManager = FileManager.default
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {

View file

@ -19,7 +19,7 @@ struct SettingsViewGeneral: View {
@AppStorage("hideEmptySections") private var hideEmptySections: Bool = false
@AppStorage("currentAppIcon") private var currentAppIcon: String = "Default"
@AppStorage("episodeSortOrder") private var episodeSortOrder: String = "Ascending"
private let metadataProvidersList = ["AniList"]
private let sortOrderOptions = ["Ascending", "Descending"]
@EnvironmentObject var settings: Settings
@ -73,9 +73,9 @@ struct SettingsViewGeneral: View {
Toggle("Hide Empty Sections", isOn: $hideEmptySections)
.tint(.accentColor)
}
Section(header: Text("Media View"), footer: Text("The episode range controls how many episodes appear on each page. Episodes are grouped into sets (like 1-25, 26-50, and so on), allowing you to navigate through them more easily.\n\nFor episode metadata it is refering to the episode thumbnail and title, since sometimes it can contain spoilers.")) {
HStack {
Text("Episodes Range")
Spacer()
@ -88,10 +88,10 @@ struct SettingsViewGeneral: View {
Text("\(episodeChunkSize)")
}
}
Toggle("Fetch Episode metadata", isOn: $fetchEpisodeMetadata)
.tint(.accentColor)
HStack {
Text("Metadata Provider")
Spacer()
@ -104,7 +104,7 @@ struct SettingsViewGeneral: View {
}
}
}
Section(header: Text("Media Grid Layout"), footer: Text("Adjust the number of media items per row in portrait and landscape modes.")) {
HStack {
if UIDevice.current.userInterfaceIdiom == .pad {
@ -133,12 +133,12 @@ struct SettingsViewGeneral: View {
}
}
}
Section(header: Text("Modules"), footer: Text("Note that the modules will be replaced only if there is a different version string inside the JSON file.")) {
Toggle("Refresh Modules on Launch", isOn: $refreshModulesOnLaunch)
.tint(.accentColor)
}
Section(header: Text("Advanced"), footer: Text("Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time.")) {
Toggle("Enable Analytics", isOn: $analyticsEnabled)
.tint(.accentColor)

View file

@ -10,7 +10,7 @@ import SwiftUI
struct SettingsViewLogger: View {
@State private var logs: String = ""
@StateObject private var filterViewModel = LogFilterViewModel.shared
var body: some View {
VStack {
ScrollView {
@ -47,7 +47,7 @@ struct SettingsViewLogger: View {
.resizable()
.frame(width: 20, height: 20)
}
NavigationLink(destination: SettingsViewLoggerFilter(viewModel: filterViewModel)) {
Image(systemName: "slider.horizontal.3")
}

View file

@ -16,13 +16,13 @@ struct LogFilter: Identifiable, Hashable {
class LogFilterViewModel: ObservableObject {
static let shared = LogFilterViewModel()
@Published var filters: [LogFilter] = [] {
didSet {
saveFiltersToUserDefaults()
}
}
private let userDefaultsKey = "LogFilterStates"
private let hardcodedFilters: [(type: String, description: String, defaultState: Bool)] = [
("General", "General events and activities.", true),
@ -31,11 +31,11 @@ class LogFilterViewModel: ObservableObject {
("Debug", "Debugging and troubleshooting.", false),
("HTMLStrings", "", false)
]
private init() {
loadFilters()
}
func loadFilters() {
if let savedStates = UserDefaults.standard.dictionary(forKey: userDefaultsKey) as? [String: Bool] {
filters = hardcodedFilters.map {
@ -51,17 +51,17 @@ class LogFilterViewModel: ObservableObject {
}
}
}
func toggleFilter(for type: String) {
if let index = filters.firstIndex(where: { $0.type == type }) {
filters[index].isEnabled.toggle()
}
}
func isFilterEnabled(for type: String) -> Bool {
return filters.first(where: { $0.type == type })?.isEnabled ?? true
}
private func saveFiltersToUserDefaults() {
let states = filters.reduce(into: [String: Bool]()) { result, filter in
result[filter.type] = filter.isEnabled
@ -72,7 +72,7 @@ class LogFilterViewModel: ObservableObject {
struct SettingsViewLoggerFilter: View {
@ObservedObject var viewModel = LogFilterViewModel.shared
var body: some View {
List {
ForEach($viewModel.filters) { $filter in

View file

@ -15,14 +15,14 @@ struct SettingsViewModule: View {
@AppStorage("selectedModuleId") private var selectedModuleId: String?
@AppStorage("hideEmptySections") private var hideEmptySections: Bool?
@AppStorage("didReceiveDefaultPageLink") private var didReceiveDefaultPageLink: Bool = false
@State private var errorMessage: String?
@State private var isLoading = false
@State private var isRefreshing = false
@State private var moduleUrl: String = ""
@State private var refreshTask: Task<Void, Never>?
@State private var showLibrary = false
var body: some View {
VStack {
Form {
@ -60,7 +60,7 @@ struct SettingsViewModule: View {
.frame(width: 50, height: 50)
.clipShape(Circle())
.padding(.trailing, 10)
VStack(alignment: .leading) {
HStack(alignment: .bottom, spacing: 4) {
Text(module.metadata.sourceName)
@ -77,9 +77,9 @@ struct SettingsViewModule: View {
.font(.subheadline)
.foregroundColor(.secondary)
}
Spacer()
if module.id.uuidString == selectedModuleId {
Image(systemName: "checkmark")
.foregroundColor(.accentColor)
@ -181,7 +181,7 @@ struct SettingsViewModule: View {
Text(errorMessage ?? "Unknown error")
}
}
func showAddModuleAlert() {
let pasteboardString = UIPasteboard.general.string ?? ""
@ -195,17 +195,17 @@ struct SettingsViewModule: View {
clipboardAlert.addAction(UIAlertAction(title: "Use Clipboard", style: .default, handler: { _ in
self.displayModuleView(url: pasteboardString)
}))
clipboardAlert.addAction(UIAlertAction(title: "Enter Manually", style: .cancel, handler: { _ in
self.showManualUrlAlert()
}))
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController {
windowScene.windows.first?.tintColor = UIColor(settings.accentColor)
rootViewController.present(clipboardAlert, animated: true, completion: nil)
}
} else {
showManualUrlAlert()
}
@ -217,18 +217,18 @@ struct SettingsViewModule: View {
message: "Enter the URL of the module file",
preferredStyle: .alert
)
alert.addTextField { textField in
textField.placeholder = "https://real.url/module.json"
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
alert.addAction(UIAlertAction(title: "Add", style: .default, handler: { _ in
if let url = alert.textFields?.first?.text, !url.isEmpty {
self.displayModuleView(url: url)
}
}))
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let rootViewController = windowScene.windows.first?.rootViewController {
windowScene.windows.first?.tintColor = UIColor(settings.accentColor)
@ -241,7 +241,7 @@ struct SettingsViewModule: View {
let addModuleView = ModuleAdditionSettingsView(moduleUrl: url)
.environmentObject(self.moduleManager)
let hostingController = UIHostingController(rootView: addModuleView)
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = windowScene.windows.first {
window.tintColor = UIColor(settings.accentColor)

View file

@ -18,9 +18,9 @@ struct SettingsViewPlayer: View {
@AppStorage("skip85Visible") private var skip85Visible: Bool = true
@AppStorage("doubleTapSeekEnabled") private var doubleTapSeekEnabled: Bool = false
@AppStorage("skipIntroOutroVisible") private var skipIntroOutroVisible: Bool = true
private let mediaPlayers = ["Default", "VLC", "OutPlayer", "Infuse", "nPlayer", "Sora"]
var body: some View {
Form {
Section(header: Text("Media Player"), footer: Text("Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.")) {
@ -37,14 +37,14 @@ struct SettingsViewPlayer: View {
}
}
}
Toggle("Force Landscape", isOn: $isAlwaysLandscape)
.tint(.accentColor)
Toggle("Two Finger Hold for Pause",isOn: $holdForPauseEnabled)
Toggle("Two Finger Hold for Pause", isOn: $holdForPauseEnabled)
.tint(.accentColor)
}
Section(header: Text("Speed Settings")) {
Toggle("Remember Playback speed", isOn: $isRememberPlaySpeed)
.tint(.accentColor)
@ -82,20 +82,20 @@ struct SettingsViewPlayer: View {
}
))
}
Section(header: Text("Skip Settings"), footer : Text("Double tapping the screen on it's sides will skip with the short tap setting.")) {
Section(header: Text("Skip Settings"), footer: Text("Double tapping the screen on it's sides will skip with the short tap setting.")) {
HStack {
Text("Tap Skip:")
Spacer()
Stepper("\(Int(skipIncrement))s", value: $skipIncrement, in: 5...300, step: 5)
}
HStack {
Text("Long press Skip:")
Spacer()
Stepper("\(Int(skipIncrementHold))s", value: $skipIncrementHold, in: 5...300, step: 5)
}
Toggle("Double Tap to Seek", isOn: $doubleTapSeekEnabled)
.tint(.accentColor)
@ -140,7 +140,7 @@ struct SubtitleSettingsSection: View {
}
}
}
HStack {
Text("Shadow")
Spacer()
@ -157,7 +157,7 @@ struct SubtitleSettingsSection: View {
}
}
}
Toggle("Background Enabled", isOn: $backgroundEnabled)
.tint(.accentColor)
.onChange(of: backgroundEnabled) { newValue in
@ -165,7 +165,7 @@ struct SubtitleSettingsSection: View {
settings.backgroundEnabled = newValue
}
}
HStack {
Text("Font Size:")
Spacer()
@ -176,7 +176,7 @@ struct SubtitleSettingsSection: View {
}
}
}
HStack {
Text("Bottom Padding:")
Spacer()
@ -187,23 +187,23 @@ struct SubtitleSettingsSection: View {
}
}
}
VStack(alignment: .leading) {
Text("Subtitle Delay: \(String(format: "%.1fs", subtitleDelay))")
.padding(.bottom, 1)
HStack {
Text("-10s")
.font(.system(size: 12))
.foregroundColor(.secondary)
Slider(value: $subtitleDelay, in: -10...10, step: 0.1)
.onChange(of: subtitleDelay) { newValue in
SubtitleSettingsManager.shared.update { settings in
settings.subtitleDelay = newValue
}
}
Text("+10s")
.font(.system(size: 12))
.foregroundColor(.secondary)

View file

@ -16,17 +16,17 @@ struct SettingsViewTrackers: View {
@State private var anilistUsername: String = ""
@State private var isAnilistLoading: Bool = false
@State private var profileColor: Color = .accentColor
@AppStorage("sendTraktUpdates") private var isSendTraktUpdates = true
@State private var traktStatus: LocalizedStringKey = "You are not logged in"
@State private var isTraktLoggedIn: Bool = false
@State private var traktUsername: String = ""
@State private var isTraktLoading: Bool = false
var body: some View {
Form {
Section(header: Text("AniList")) {
HStack() {
HStack {
KFImage(URL(string: "https://raw.githubusercontent.com/cranci1/Ryu/2f10226aa087154974a70c1ec78aa83a47daced9/Ryu/Assets.xcassets/Listing/Anilist.imageset/anilist.png"))
.placeholder {
RoundedRectangle(cornerRadius: 10)
@ -41,7 +41,7 @@ struct SettingsViewTrackers: View {
Text("AniList.co")
.font(.title2)
}
if isAnilistLoading {
ProgressView()
} else {
@ -58,12 +58,12 @@ struct SettingsViewTrackers: View {
.multilineTextAlignment(.center)
}
}
if isAnilistLoggedIn {
Toggle("Sync anime progress", isOn: $isSendPushUpdates)
.tint(.accentColor)
}
Button(isAnilistLoggedIn ? "Log Out from AniList" : "Log In with AniList") {
if isAnilistLoggedIn {
logoutAniList()
@ -73,9 +73,9 @@ struct SettingsViewTrackers: View {
}
.font(.body)
}
Section(header: Text("Trakt")) {
HStack() {
HStack {
KFImage(URL(string: "https://static-00.iconduck.com/assets.00/trakt-icon-2048x2048-2633ksxg.png"))
.placeholder {
RoundedRectangle(cornerRadius: 10)
@ -90,7 +90,7 @@ struct SettingsViewTrackers: View {
Text("Trakt.tv")
.font(.title2)
}
if isTraktLoading {
ProgressView()
} else {
@ -106,7 +106,7 @@ struct SettingsViewTrackers: View {
.multilineTextAlignment(.center)
}
}
Button(isTraktLoggedIn ? "Log Out from Trakt" : "Log In with Trakt") {
if isTraktLoggedIn {
logoutTrakt()
@ -116,7 +116,7 @@ struct SettingsViewTrackers: View {
}
.font(.body)
}
Section(footer: Text("Sora and cranci1 are not affiliated with AniList nor Trakt in any way.\n\nAlso note that progresses update may not be 100% accurate.")) {}
}
.navigationTitle("Trackers")
@ -129,21 +129,21 @@ struct SettingsViewTrackers: View {
removeNotificationObservers()
}
}
func removeNotificationObservers() {
NotificationCenter.default.removeObserver(self, name: AniListToken.authSuccessNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: AniListToken.authFailureNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: TraktToken.authSuccessNotification, object: nil)
NotificationCenter.default.removeObserver(self, name: TraktToken.authFailureNotification, object: nil)
}
func setupNotificationObservers() {
NotificationCenter.default.addObserver(forName: AniListToken.authSuccessNotification, object: nil, queue: .main) { _ in
self.anilistStatus = "Authentication successful!"
self.updateAniListStatus()
}
NotificationCenter.default.addObserver(forName: AniListToken.authFailureNotification, object: nil, queue: .main) { notification in
if let error = notification.userInfo?["error"] as? String {
self.anilistStatus = "Login failed: \(error)"
@ -153,12 +153,12 @@ struct SettingsViewTrackers: View {
self.isAnilistLoggedIn = false
self.isAnilistLoading = false
}
NotificationCenter.default.addObserver(forName: TraktToken.authSuccessNotification, object: nil, queue: .main) { _ in
self.traktStatus = "Authentication successful!"
self.updateTraktStatus()
}
NotificationCenter.default.addObserver(forName: TraktToken.authFailureNotification, object: nil, queue: .main) { notification in
if let error = notification.userInfo?["error"] as? String {
self.traktStatus = "Login failed: \(error)"
@ -169,20 +169,20 @@ struct SettingsViewTrackers: View {
self.isTraktLoading = false
}
}
func loginTrakt() {
traktStatus = "Starting authentication..."
isTraktLoading = true
TraktLogin.authenticate()
}
func logoutTrakt() {
removeTraktTokenFromKeychain()
traktStatus = "You are not logged in"
isTraktLoggedIn = false
traktUsername = ""
}
func updateTraktStatus() {
if let token = getTraktTokenFromKeychain() {
isTraktLoggedIn = true
@ -192,7 +192,7 @@ struct SettingsViewTrackers: View {
traktStatus = "You are not logged in"
}
}
func fetchTraktUserInfo(token: String) {
isTraktLoading = true
let userInfoURL = URL(string: "https://api.trakt.tv/users/settings")!
@ -202,20 +202,20 @@ struct SettingsViewTrackers: View {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("2", forHTTPHeaderField: "trakt-api-version")
request.setValue(TraktToken.clientID, forHTTPHeaderField: "trakt-api-key")
URLSession.shared.dataTask(with: request) { data, response, error in
URLSession.shared.dataTask(with: request) { data, _, error in
DispatchQueue.main.async {
self.isTraktLoading = false
if let error = error {
self.traktStatus = "Error: \(error.localizedDescription)"
return
}
guard let data = data else {
self.traktStatus = "No data received"
return
}
do {
if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let user = json["user"] as? [String: Any],
@ -229,7 +229,7 @@ struct SettingsViewTrackers: View {
}
}.resume()
}
func getTraktTokenFromKeychain() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
@ -238,7 +238,7 @@ struct SettingsViewTrackers: View {
kSecReturnData as String: kCFBooleanTrue!,
kSecMatchLimit as String: kSecMatchLimitOne
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status == errSecSuccess,
@ -248,7 +248,7 @@ struct SettingsViewTrackers: View {
}
return token
}
func removeTraktTokenFromKeychain() {
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
@ -256,7 +256,7 @@ struct SettingsViewTrackers: View {
kSecAttrAccount as String: TraktToken.accessTokenKey
]
SecItemDelete(deleteQuery as CFDictionary)
let refreshDeleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: TraktToken.serviceName,
@ -264,13 +264,13 @@ struct SettingsViewTrackers: View {
]
SecItemDelete(refreshDeleteQuery as CFDictionary)
}
func loginAniList() {
anilistStatus = "Starting authentication..."
isAnilistLoading = true
AniListLogin.authenticate()
}
func logoutAniList() {
removeTokenFromKeychain()
anilistStatus = "You are not logged in"
@ -278,7 +278,7 @@ struct SettingsViewTrackers: View {
anilistUsername = ""
profileColor = .primary
}
func updateAniListStatus() {
if let token = getTokenFromKeychain() {
isAnilistLoggedIn = true
@ -288,7 +288,7 @@ struct SettingsViewTrackers: View {
anilistStatus = "You are not logged in"
}
}
func fetchUserInfo(token: String) {
isAnilistLoading = true
let userInfoURL = URL(string: "https://graphql.anilist.co")!
@ -296,7 +296,7 @@ struct SettingsViewTrackers: View {
request.httpMethod = "POST"
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let query = """
{
Viewer {
@ -309,7 +309,7 @@ struct SettingsViewTrackers: View {
}
"""
let body: [String: Any] = ["query": query]
do {
request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [])
} catch {
@ -318,8 +318,8 @@ struct SettingsViewTrackers: View {
isAnilistLoading = false
return
}
URLSession.shared.dataTask(with: request) { data, response, error in
URLSession.shared.dataTask(with: request) { data, _, error in
DispatchQueue.main.async {
isAnilistLoading = false
if let error = error {
@ -339,7 +339,7 @@ struct SettingsViewTrackers: View {
let name = viewer["name"] as? String,
let options = viewer["options"] as? [String: Any],
let colorName = options["profileColor"] as? String {
anilistUsername = name
profileColor = colorFromName(colorName)
anilistStatus = "Logged in as \(name)"
@ -354,7 +354,7 @@ struct SettingsViewTrackers: View {
}
}.resume()
}
func colorFromName(_ name: String) -> Color {
switch name.lowercased() {
case "blue":
@ -375,7 +375,7 @@ struct SettingsViewTrackers: View {
return .accentColor
}
}
func getTokenFromKeychain() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
@ -391,7 +391,7 @@ struct SettingsViewTrackers: View {
}
return String(data: tokenData, encoding: .utf8)
}
func removeTokenFromKeychain() {
let deleteQuery: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,

View file

@ -34,7 +34,7 @@ struct SettingsView: View {
Text("Trackers")
}
}
Section(header: Text("Diagnostics & Storage")) {
NavigationLink(destination: SettingsViewData()) {
Text("Data")
@ -43,7 +43,7 @@ struct SettingsView: View {
Text("Logs")
}
}
Section(
header: Text("Info"),
footer: Text("Running Sora \(version) - cranci1")
@ -123,7 +123,7 @@ struct SettingsView: View {
enum Appearance: String, CaseIterable, Identifiable {
case system, light, dark
var id: String { self.rawValue }
}
@ -145,7 +145,7 @@ class Settings: ObservableObject {
updateAppearance()
}
}
init() {
if let shimmerRawValue = UserDefaults.standard.string(forKey: "shimmerType"),
let shimmer = ShimmerType(rawValue: shimmerRawValue) {
@ -161,7 +161,6 @@ class Settings: ObservableObject {
self.accentColor = .accentColor
}
if let appearanceRawValue = UserDefaults.standard.string(forKey: "selectedAppearance"),
let appearance = Appearance(rawValue: appearanceRawValue) {
self.selectedAppearance = appearance
@ -188,7 +187,7 @@ class Settings: ObservableObject {
Logger.shared.log("Failed to save accent color: \(error.localizedDescription)")
}
}
func updateAppearance() {
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
switch selectedAppearance {

View file

@ -106,13 +106,13 @@
buildConfigurationList = 1207646F2DB6F6E1003621E9 /* Build configuration list for PBXNativeTarget "SulfurTV" */;
buildPhases = (
120764612DB6F6E0003621E9 /* Sources */,
12DAC1832DBE3C1C00B31A65 /* ShellScript */,
120764622DB6F6E0003621E9 /* Frameworks */,
120764632DB6F6E0003621E9 /* Resources */,
);
buildRules = (
);
dependencies = (
120D3C702DBA3DF90093D596 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
120764662DB6F6E0003621E9 /* SulfurTV */,
@ -129,13 +129,13 @@
buildConfigurationList = 133D7C782D2BE2520075467E /* Build configuration list for PBXNativeTarget "Sulfur" */;
buildPhases = (
133D7C662D2BE2500075467E /* Sources */,
12DAC1822DBE3C0300B31A65 /* ShellScript */,
133D7C672D2BE2500075467E /* Frameworks */,
133D7C682D2BE2500075467E /* Resources */,
);
buildRules = (
);
dependencies = (
120D3C6E2DBA3DF30093D596 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
126C42F62DB9AA97006BC27D /* Sora */,
@ -184,7 +184,6 @@
132E351E2D959E1D0007800E /* XCRemoteSwiftPackageReference "FFmpeg-iOS-Lame" */,
132E35212D959E410007800E /* XCRemoteSwiftPackageReference "Kingfisher" */,
13B77E172DA44F8300126FDF /* XCRemoteSwiftPackageReference "MarqueeLabel" */,
120D3C6C2DBA3D790093D596 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */,
);
productRefGroup = 133D7C6B2D2BE2500075467E /* Products */;
projectDirPath = "";
@ -217,6 +216,43 @@
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
12DAC1822DBE3C0300B31A65 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if command -v swiftlint >/dev/null 2>&1\nthen\n swiftlint --fix && swiftlint\nelse\n echo \"warning: `swiftlint` command not found - See https://github.com/realm/SwiftLint#installation for installation instructions.\"\nfi\n";
};
12DAC1832DBE3C1C00B31A65 /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "if command -v swiftlint >/dev/null 2>&1\nthen\n swiftlint --fix && swiftlint\nelse\n echo \"warning: `swiftlint` command not found - See https://github.com/realm/SwiftLint#installation for installation instructions.\"\nfi\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
120764612DB6F6E0003621E9 /* Sources */ = {
isa = PBXSourcesBuildPhase;
@ -234,17 +270,6 @@
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
120D3C6E2DBA3DF30093D596 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
productRef = 120D3C6D2DBA3DF30093D596 /* SwiftLintBuildToolPlugin */;
};
120D3C702DBA3DF90093D596 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
productRef = 120D3C6F2DBA3DF90093D596 /* SwiftLintBuildToolPlugin */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
1207646D2DB6F6E1003621E9 /* Debug */ = {
isa = XCBuildConfiguration;
@ -575,14 +600,6 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
120D3C6C2DBA3D790093D596 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/SimplyDanny/SwiftLintPlugins";
requirement = {
branch = main;
kind = branch;
};
};
132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/omaralbeik/Drops.git";
@ -618,16 +635,6 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
120D3C6D2DBA3DF30093D596 /* SwiftLintBuildToolPlugin */ = {
isa = XCSwiftPackageProductDependency;
package = 120D3C6C2DBA3D790093D596 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */;
productName = "plugin:SwiftLintBuildToolPlugin";
};
120D3C6F2DBA3DF90093D596 /* SwiftLintBuildToolPlugin */ = {
isa = XCSwiftPackageProductDependency;
package = 120D3C6C2DBA3D790093D596 /* XCRemoteSwiftPackageReference "SwiftLintPlugins" */;
productName = "plugin:SwiftLintBuildToolPlugin";
};
132E351C2D959DDB0007800E /* Drops */ = {
isa = XCSwiftPackageProductDependency;
package = 132E351B2D959DDB0007800E /* XCRemoteSwiftPackageReference "Drops" */;

View file

@ -1,5 +1,5 @@
{
"originHash" : "c4909124df3eb22bfcc539fb1f3936eb79c309605d2f34c5b02efa1fe3f48447",
"originHash" : "e772caa8d6a8793d24bf04e3d77695cd5ac695f3605d2b657e40115caedf8863",
"pins" : [
{
"identity" : "drops",
@ -45,15 +45,6 @@
"branch" : "master",
"revision" : "18e4787f4dc1c26d2d581c4bc9aeae34686eeeae"
}
},
{
"identity" : "swiftlintplugins",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SimplyDanny/SwiftLintPlugins",
"state" : {
"branch" : "main",
"revision" : "8545ddf4de043e6f2051c5cf204f39ef778ebf6b"
}
}
],
"version" : 3

View file

@ -26,19 +26,19 @@ struct InfoView: View {
.background(.primary)
.cornerRadius(30)
}
// Text + Close Button
VStack(alignment: .leading, spacing: 40) {
Text("Scan to Visit")
.font(.largeTitle)
.bold()
.foregroundColor(.primary)
Text("Links cannot be openend on tvOS. But you can either Scan the QR-Code or type in the following URL into the Browser of your choosing: \n\n\(urlString)")
.font(.subheadline)
.foregroundColor(.secondary)
.frame(maxWidth: 500)
Button("Close") {
dismiss()
}