mirror of
https://github.com/cranci1/Sora.git
synced 2026-01-11 20:10:24 +00:00
Merge branch 'dev'
This commit is contained in:
commit
0f8acca4df
51 changed files with 7911 additions and 3643 deletions
35
README.md
35
README.md
|
|
@ -24,12 +24,12 @@
|
|||
|
||||
- [x] macOS 12.0+ support
|
||||
- [x] iOS/iPadOS 15.0+ support
|
||||
- [x] JavaScript as main Loader
|
||||
- [x] JavaScript as main loader
|
||||
- [x] Download support (HLS & MP4)
|
||||
- [x] Tracking Services (AniList, Trakt)
|
||||
- [x] Apple KeyChain support for auth Tokens
|
||||
- [x] Streams support (Jellyfin/Plex like servers)
|
||||
- [x] External Metadata providers (TMDB, AniList)
|
||||
- [x] Tracking services (AniList, Trakt)
|
||||
- [x] Apple Keychain support for auth tokens
|
||||
- [x] Streams support (Jellyfin/Plex-like servers)
|
||||
- [x] External metadata providers (TMDB, AniList)
|
||||
- [x] Background playback and Picture-in-Picture (PiP) support
|
||||
- [x] External media player support (VLC, Infuse, Outplayer, nPlayer, SenPlayer, IINA, TracyPlayer)
|
||||
|
||||
|
|
@ -49,23 +49,24 @@ Additionally, you can install the app using Xcode or using the .ipa file, which
|
|||
|
||||
## Frequently Asked Questions
|
||||
|
||||
1. **What is Sora?**
|
||||
Sora is a modular web scraping application designed to work exclusively with custom modules.
|
||||
1. **What is Sora?**
|
||||
Sora is a modular web scraping application designed to work exclusively with custom modules.
|
||||
|
||||
2. **Is Sora safe?**
|
||||
Yes, Sora is open-source and prioritizes user privacy. It does not store user data on external servers and does not collect crash logs.
|
||||
2. **Is Sora safe?**
|
||||
Yes, Sora is open-source and prioritizes user privacy. It does not store user data on external servers and does not collect crash logs.
|
||||
|
||||
3. **Will Sora ever be paid?**
|
||||
No, Sora will always remain free without subscriptions, paid content, or any type of login.
|
||||
3. **Will Sora ever be paid?**
|
||||
No, Sora will always remain free without subscriptions, paid content, or any type of login.
|
||||
|
||||
4. **How can I get modules?**
|
||||
Sora does not include any modules by default. You will need to find and add the necessary modules yourself, or create your own.
|
||||
4. **How can I get modules?**
|
||||
Sora does not include any modules by default. You will need to find and add the necessary modules yourself, or create your own.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
Frameworks:
|
||||
- [Drops](https://github.com/omaralbeik/Drops) - MIT License
|
||||
- [NukeUI](https://github.com/kean/NukeUI) - MIT License
|
||||
- [SoraCore](https://github.com/cranci1/SoraCore) - Custom License
|
||||
- [MarqueeLabel](https://github.com/cbpowell/MarqueeLabel) - MIT License
|
||||
|
||||
Misc:
|
||||
|
|
@ -95,16 +96,16 @@ along with Sora. If not, see <https://www.gnu.org/licenses/>.
|
|||
|
||||
## Legal
|
||||
|
||||
**_Sora is not made for Piracy! The Sora project does not condone any form of piracy._**
|
||||
**_Sora is not intended for piracy. The Sora project does not endorse or support any form of piracy._**
|
||||
|
||||
### No Liability
|
||||
|
||||
The developer(s) of this software assumes no liability for damages, legal claims, or other issues arising from the use or misuse of this software or any third-party modules. Users bear full responsibility for their actions. Use this software and modules at your own risk.
|
||||
The developer(s) of this software assume no liability for damages, legal claims, or other issues arising from the use or misuse of this software or any third-party modules. Users bear full responsibility for their actions. Use of this software and its modules is at your own risk.
|
||||
|
||||
### Third-Party Websites and Intellectual Property
|
||||
|
||||
This software is not affiliated with or endorsed by any third-party entity. Any references to third-party sites in user-generated modules do not imply endorsement. Users are responsible for verifying that their scraping activities comply with the terms of service and intellectual property rights of the sites they interact with.
|
||||
This software is not affiliated with or endorsed by any third-party entities. Any references to third-party sites in user-generated modules do not imply endorsement. Users are responsible for ensuring their scraping activities comply with the terms of service and intellectual property rights of the sites they interact with.
|
||||
|
||||
### DMCA
|
||||
|
||||
The developer(s) are not responsible for the misuse of any content inside or outside the app and shall not be responsible for the dissemination of any content within the app. Any violations should be sent to the source website or module creator. The developer is not legally responsible for any module used inside the app.
|
||||
The developer(s) are not responsible for the misuse of any content inside or outside the app and shall not be held liable for the dissemination of any content within the app. Any violations should be reported to the source website or module creator. The developer bears no legal responsibility for any module used within the app.
|
||||
|
|
|
|||
21
Sora/Assets.xcassets/SplashScreenIcon.imageset/Contents.json
vendored
Normal file
21
Sora/Assets.xcassets/SplashScreenIcon.imageset/Contents.json
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "SplashScreenIcon.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
Sora/Assets.xcassets/SplashScreenIcon.imageset/SplashScreenIcon.png
vendored
Normal file
BIN
Sora/Assets.xcassets/SplashScreenIcon.imageset/SplashScreenIcon.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
File diff suppressed because it is too large
Load diff
|
|
@ -6,54 +6,12 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
class OrientationManager: ObservableObject {
|
||||
static let shared = OrientationManager()
|
||||
|
||||
@Published var isLocked = false
|
||||
private var lockedOrientation: UIInterfaceOrientationMask = .all
|
||||
|
||||
private init() {}
|
||||
|
||||
func lockOrientation() {
|
||||
let currentOrientation = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation ?? .portrait
|
||||
|
||||
switch currentOrientation {
|
||||
case .portrait, .portraitUpsideDown:
|
||||
lockedOrientation = .portrait
|
||||
case .landscapeLeft, .landscapeRight:
|
||||
lockedOrientation = .landscape
|
||||
default:
|
||||
lockedOrientation = .portrait
|
||||
}
|
||||
|
||||
isLocked = true
|
||||
|
||||
UIDevice.current.setValue(currentOrientation.rawValue, forKey: "orientation")
|
||||
UIViewController.attemptRotationToDeviceOrientation()
|
||||
}
|
||||
|
||||
func unlockOrientation(after delay: TimeInterval = 0.0) {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||
self.isLocked = false
|
||||
self.lockedOrientation = .all
|
||||
|
||||
UIViewController.attemptRotationToDeviceOrientation()
|
||||
}
|
||||
}
|
||||
|
||||
func supportedOrientations() -> UIInterfaceOrientationMask {
|
||||
return isLocked ? lockedOrientation : .all
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
struct SoraApp: App {
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
@StateObject private var settings = Settings()
|
||||
@StateObject private var moduleManager = ModuleManager()
|
||||
@StateObject private var librarykManager = LibraryManager()
|
||||
@StateObject private var libraryManager = LibraryManager()
|
||||
@StateObject private var downloadManager = DownloadManager()
|
||||
@StateObject private var jsController = JSController.shared
|
||||
|
||||
|
|
@ -73,28 +31,30 @@ struct SoraApp: App {
|
|||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(moduleManager)
|
||||
.environmentObject(settings)
|
||||
.environmentObject(librarykManager)
|
||||
.environmentObject(downloadManager)
|
||||
.environmentObject(jsController)
|
||||
.accentColor(settings.accentColor)
|
||||
.onAppear {
|
||||
settings.updateAppearance()
|
||||
Task {
|
||||
if UserDefaults.standard.bool(forKey: "refreshModulesOnLaunch") {
|
||||
await moduleManager.refreshModules()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onOpenURL { url in
|
||||
if let params = url.queryParameters, params["code"] != nil {
|
||||
Self.handleRedirect(url: url)
|
||||
} else {
|
||||
handleURL(url)
|
||||
Group {
|
||||
if !UserDefaults.standard.bool(forKey: "hideSplashScreen") {
|
||||
SplashScreenView()
|
||||
} else {
|
||||
ContentView()
|
||||
}
|
||||
}
|
||||
.environmentObject(moduleManager)
|
||||
.environmentObject(settings)
|
||||
.environmentObject(libraryManager)
|
||||
.environmentObject(downloadManager)
|
||||
.environmentObject(jsController)
|
||||
.accentColor(settings.accentColor)
|
||||
.onAppear {
|
||||
settings.updateAppearance()
|
||||
Task {
|
||||
if UserDefaults.standard.bool(forKey: "refreshModulesOnLaunch") {
|
||||
await moduleManager.refreshModules()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onOpenURL { url in
|
||||
handleURL(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -142,50 +102,4 @@ struct SoraApp: App {
|
|||
break
|
||||
}
|
||||
}
|
||||
|
||||
static func handleRedirect(url: URL) {
|
||||
guard let params = url.queryParameters,
|
||||
let code = params["code"] else {
|
||||
Logger.shared.log("Failed to extract authorization code")
|
||||
return
|
||||
}
|
||||
|
||||
switch url.host {
|
||||
case "anilist":
|
||||
AniListToken.exchangeAuthorizationCodeForToken(code: code) { success in
|
||||
if success {
|
||||
Logger.shared.log("AniList token exchange successful")
|
||||
} else {
|
||||
Logger.shared.log("AniList token exchange failed", type: "Error")
|
||||
}
|
||||
}
|
||||
|
||||
case "trakt":
|
||||
TraktToken.exchangeAuthorizationCodeForToken(code: code) { success in
|
||||
if success {
|
||||
Logger.shared.log("Trakt token exchange successful")
|
||||
} else {
|
||||
Logger.shared.log("Trakt token exchange failed", type: "Error")
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
Logger.shared.log("Unknown authentication service", type: "Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
class AppInfo: NSObject {
|
||||
@objc func getBundleIdentifier() -> String {
|
||||
return Bundle.main.bundleIdentifier ?? "me.cranci.sulfur"
|
||||
}
|
||||
|
||||
@objc func getDisplayName() -> String {
|
||||
return Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as! String
|
||||
}
|
||||
}
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
|
||||
return OrientationManager.shared.supportedOrientations()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -16,19 +16,28 @@ class AniListLogin {
|
|||
static func authenticate() {
|
||||
let urlString = "\(authorizationEndpoint)?client_id=\(clientID)&redirect_uri=\(redirectURI)&response_type=code"
|
||||
guard let url = URL(string: urlString) else {
|
||||
Logger.shared.log("Invalid authorization URL", type: "Error")
|
||||
return
|
||||
}
|
||||
|
||||
if UIApplication.shared.canOpenURL(url) {
|
||||
UIApplication.shared.open(url, options: [:]) { success in
|
||||
if success {
|
||||
Logger.shared.log("Safari opened successfully", type: "Debug")
|
||||
WebAuthenticationManager.shared.authenticate(url: url, callbackScheme: "sora") { result in
|
||||
switch result {
|
||||
case .success(let callbackURL):
|
||||
if let params = callbackURL.queryParameters,
|
||||
let code = params["code"] {
|
||||
AniListToken.exchangeAuthorizationCodeForToken(code: code) { success in
|
||||
if success {
|
||||
Logger.shared.log("AniList token exchange successful", type: "Debug")
|
||||
} else {
|
||||
Logger.shared.log("AniList token exchange failed", type: "Error")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.shared.log("Failed to open Safari", type: "Error")
|
||||
Logger.shared.log("No authorization code in callback URL", type: "Error")
|
||||
}
|
||||
case .failure(let error):
|
||||
Logger.shared.log("Authentication failed: \(error.localizedDescription)", type: "Error")
|
||||
}
|
||||
} else {
|
||||
Logger.shared.log("Cannot open URL", type: "Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,6 +195,50 @@ class AniListMutation {
|
|||
}.resume()
|
||||
}
|
||||
|
||||
func fetchCoverImage(
|
||||
animeId: Int,
|
||||
completion: @escaping (Result<String, Error>) -> Void
|
||||
) {
|
||||
let query = """
|
||||
query ($id: Int) {
|
||||
Media(id: $id, type: ANIME) {
|
||||
coverImage { large }
|
||||
}
|
||||
}
|
||||
"""
|
||||
let variables = ["id": animeId]
|
||||
let body: [String: Any] = ["query": query, "variables": variables]
|
||||
|
||||
guard let url = URL(string: "https://graphql.anilist.co"),
|
||||
let httpBody = try? JSONSerialization.data(withJSONObject: body)
|
||||
else {
|
||||
completion(.failure(NSError(domain: "AniList", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid URL or payload"])))
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = httpBody
|
||||
|
||||
URLSession.shared.dataTask(with: request) { data, _, error in
|
||||
if let error = error {
|
||||
return completion(.failure(error))
|
||||
}
|
||||
guard let data = data,
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let dataDict = json["data"] as? [String: Any],
|
||||
let media = dataDict["Media"] as? [String: Any],
|
||||
let cover = media["coverImage"] as? [String: Any],
|
||||
let imageUrl = cover["large"] as? String
|
||||
else {
|
||||
return completion(.failure(NSError(domain: "AniList", code: 1, userInfo: [NSLocalizedDescriptionKey: "Malformed response"])))
|
||||
}
|
||||
completion(.success(imageUrl))
|
||||
}
|
||||
.resume()
|
||||
}
|
||||
|
||||
private struct AniListMediaResponse: Decodable {
|
||||
struct DataField: Decodable {
|
||||
struct Media: Decodable { let idMal: Int? }
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ class TMDBFetcher {
|
|||
let results: [TMDBResult]
|
||||
}
|
||||
|
||||
private let apiKey = "738b4edd0a156cc126dc4a4b8aea4aca"
|
||||
let apiKey = "738b4edd0a156cc126dc4a4b8aea4aca"
|
||||
private let session = URLSession.custom
|
||||
|
||||
func fetchBestMatchID(for title: String, completion: @escaping (Int?, MediaType?) -> Void) {
|
||||
|
|
|
|||
|
|
@ -16,19 +16,28 @@ class TraktLogin {
|
|||
static func authenticate() {
|
||||
let urlString = "\(authorizationEndpoint)?client_id=\(clientID)&redirect_uri=\(redirectURI)&response_type=code"
|
||||
guard let url = URL(string: urlString) else {
|
||||
Logger.shared.log("Invalid authorization URL", type: "Error")
|
||||
return
|
||||
}
|
||||
|
||||
if UIApplication.shared.canOpenURL(url) {
|
||||
UIApplication.shared.open(url, options: [:]) { success in
|
||||
if success {
|
||||
Logger.shared.log("Safari opened successfully", type: "Debug")
|
||||
WebAuthenticationManager.shared.authenticate(url: url, callbackScheme: "sora") { result in
|
||||
switch result {
|
||||
case .success(let callbackURL):
|
||||
if let params = callbackURL.queryParameters,
|
||||
let code = params["code"] {
|
||||
TraktToken.exchangeAuthorizationCodeForToken(code: code) { success in
|
||||
if success {
|
||||
Logger.shared.log("Trakt token exchange successful", type: "Debug")
|
||||
} else {
|
||||
Logger.shared.log("Trakt token exchange failed", type: "Error")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.shared.log("Failed to open Safari", type: "Error")
|
||||
Logger.shared.log("No authorization code in callback URL", type: "Error")
|
||||
}
|
||||
case .failure(let error):
|
||||
Logger.shared.log("Authentication failed: \(error.localizedDescription)", type: "Error")
|
||||
}
|
||||
} else {
|
||||
Logger.shared.log("Cannot open URL", type: "Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,37 +25,26 @@ class TraktMutation {
|
|||
guard status == errSecSuccess,
|
||||
let tokenData = item as? Data,
|
||||
let token = String(data: tokenData, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
enum ExternalIDType {
|
||||
case imdb(String)
|
||||
case tmdb(Int)
|
||||
|
||||
var dictionary: [String: Any] {
|
||||
switch self {
|
||||
case .imdb(let id):
|
||||
return ["imdb": id]
|
||||
case .tmdb(let id):
|
||||
return ["tmdb": id]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func markAsWatched(type: String, externalID: ExternalIDType, episodeNumber: Int? = nil, seasonNumber: Int? = nil, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
if let sendTraktUpdates = UserDefaults.standard.object(forKey: "sendTraktUpdates") as? Bool,
|
||||
sendTraktUpdates == false {
|
||||
func markAsWatched(type: String, tmdbID: Int, episodeNumber: Int? = nil, seasonNumber: Int? = nil, completion: @escaping (Result<Void, Error>) -> Void) {
|
||||
let sendTraktUpdates = UserDefaults.standard.object(forKey: "sendTraktUpdates") as? Bool ?? true
|
||||
if !sendTraktUpdates {
|
||||
completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Trakt updates disabled by user"])))
|
||||
return
|
||||
}
|
||||
|
||||
guard let userToken = getTokenFromKeychain() else {
|
||||
Logger.shared.log("Trakt access token not found in keychain", type: "Error")
|
||||
completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Access token not found"])))
|
||||
return
|
||||
}
|
||||
|
||||
let endpoint = "/sync/history"
|
||||
let watchedAt = ISO8601DateFormatter().string(from: Date())
|
||||
let body: [String: Any]
|
||||
|
||||
switch type {
|
||||
|
|
@ -63,26 +52,33 @@ class TraktMutation {
|
|||
body = [
|
||||
"movies": [
|
||||
[
|
||||
"ids": externalID.dictionary
|
||||
"ids": ["tmdb": tmdbID],
|
||||
"watched_at": watchedAt
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
case "episode":
|
||||
guard let episode = episodeNumber, let season = seasonNumber else {
|
||||
completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing episode or season number"])))
|
||||
let errorMsg = "Missing episode (\(episodeNumber ?? -1)) or season (\(seasonNumber ?? -1)) number"
|
||||
Logger.shared.log(errorMsg, type: "Error")
|
||||
completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: errorMsg])))
|
||||
return
|
||||
}
|
||||
|
||||
Logger.shared.log("Preparing episode watch request - TMDB ID: \(tmdbID), Season: \(season), Episode: \(episode)", type: "Debug")
|
||||
body = [
|
||||
"shows": [
|
||||
[
|
||||
"ids": externalID.dictionary,
|
||||
"ids": ["tmdb": tmdbID],
|
||||
"seasons": [
|
||||
[
|
||||
"number": season,
|
||||
"episodes": [
|
||||
["number": episode]
|
||||
[
|
||||
"number": episode,
|
||||
"watched_at": watchedAt
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
|
@ -91,39 +87,65 @@ class TraktMutation {
|
|||
]
|
||||
|
||||
default:
|
||||
Logger.shared.log("Invalid content type: \(type)", type: "Error")
|
||||
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("Bearer \(userToken)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("2", forHTTPHeaderField: "trakt-api-version")
|
||||
request.setValue(TraktToken.clientID, forHTTPHeaderField: "trakt-api-key")
|
||||
|
||||
do {
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: body, options: [.prettyPrinted])
|
||||
request.httpBody = jsonData
|
||||
|
||||
if let jsonString = String(data: jsonData, encoding: .utf8) {
|
||||
Logger.shared.log("Trakt API Request Body: \(jsonString)", type: "Debug")
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Failed to serialize request body: \(error.localizedDescription)", type: "Error")
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
if let error = error {
|
||||
Logger.shared.log("Trakt API network error: \(error.localizedDescription)", type: "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
|
||||
}
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
Logger.shared.log("Trakt API: No HTTP response received", type: "Error")
|
||||
completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "No HTTP response"])))
|
||||
return
|
||||
}
|
||||
|
||||
Logger.shared.log("Successfully updated watch status on Trakt", type: "Debug")
|
||||
completion(.success(()))
|
||||
if let data = data, let responseString = String(data: data, encoding: .utf8) {
|
||||
Logger.shared.log("Trakt API Response Body: \(responseString)", type: "Debug")
|
||||
}
|
||||
|
||||
if (200...299).contains(httpResponse.statusCode) {
|
||||
Logger.shared.log("Successfully updated watch status on Trakt for \(type)", type: "General")
|
||||
completion(.success(()))
|
||||
} else {
|
||||
var errorMessage = "HTTP \(httpResponse.statusCode)"
|
||||
if let data = data,
|
||||
let errorJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
if let error = errorJson["error"] as? String {
|
||||
errorMessage = "\(errorMessage): \(error)"
|
||||
}
|
||||
if let errorDescription = errorJson["error_description"] as? String {
|
||||
errorMessage = "\(errorMessage) - \(errorDescription)"
|
||||
}
|
||||
}
|
||||
Logger.shared.log("Trakt API Error: \(errorMessage)", type: "Error")
|
||||
completion(.failure(NSError(domain: "", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: errorMessage])))
|
||||
}
|
||||
}
|
||||
|
||||
task.resume()
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ class DownloadManager: NSObject, ObservableObject {
|
|||
localPlaybackURL = localURL
|
||||
}
|
||||
} catch {
|
||||
print("Error loading local content: \(error)")
|
||||
Logger.shared.log("Could not load local content: \(error)", type: "Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -71,7 +71,7 @@ extension DownloadManager: AVAssetDownloadDelegate {
|
|||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
||||
guard let error = error else { return }
|
||||
print("Download error: \(error.localizedDescription)")
|
||||
Logger.shared.log("Download failed: \(error.localizedDescription)", type: "Error")
|
||||
activeDownloadTasks.removeValue(forKey: task)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,13 +32,13 @@ enum DownloadQualityPreference: String, CaseIterable {
|
|||
var description: String {
|
||||
switch self {
|
||||
case .best:
|
||||
return "Highest available quality (largest file size)"
|
||||
return "Maximum quality available (largest file size)"
|
||||
case .high:
|
||||
return "High quality (720p or higher)"
|
||||
return "High quality (720p or better)"
|
||||
case .medium:
|
||||
return "Medium quality (480p-720p)"
|
||||
return "Medium quality (480p to 720p)"
|
||||
case .low:
|
||||
return "Lowest available quality (smallest file size)"
|
||||
return "Minimum quality available (smallest file size)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,13 +16,13 @@ enum M3U8StreamExtractorError: Error {
|
|||
var localizedDescription: String {
|
||||
switch self {
|
||||
case .networkError(let error):
|
||||
return "Network error: \(error.localizedDescription)"
|
||||
return "Connection error: \(error.localizedDescription)"
|
||||
case .parsingError(let message):
|
||||
return "Parsing error: \(message)"
|
||||
return "Stream parsing error: \(message)"
|
||||
case .noStreamFound:
|
||||
return "No suitable stream found in playlist"
|
||||
return "No compatible stream found in playlist"
|
||||
case .invalidURL:
|
||||
return "Invalid stream URL"
|
||||
return "Stream URL is invalid"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,8 +67,8 @@ class DropManager {
|
|||
let willStartImmediately = JSController.shared.willDownloadStartImmediately()
|
||||
|
||||
let message = willStartImmediately
|
||||
? "Episode \(episodeNumber) download started"
|
||||
: "Episode \(episodeNumber) queued"
|
||||
? "Episode \(episodeNumber) is now downloading"
|
||||
: "Episode \(episodeNumber) added to download queue"
|
||||
|
||||
showDrop(
|
||||
title: willStartImmediately ? "Download Started" : "Download Queued",
|
||||
|
|
|
|||
|
|
@ -5,15 +5,13 @@
|
|||
// Created by Hamzo on 19/03/25.
|
||||
//
|
||||
|
||||
import SoraCore
|
||||
import JavaScriptCore
|
||||
|
||||
extension JSContext {
|
||||
func setupConsoleLogging() {
|
||||
let consoleObject = JSValue(newObjectIn: self)
|
||||
|
||||
let appInfoBridge = AppInfo()
|
||||
consoleObject?.setObject(appInfoBridge, forKeyedSubscript: "AppInfo" as NSString)
|
||||
|
||||
let consoleLogFunction: @convention(block) (String) -> Void = { message in
|
||||
Logger.shared.log(message, type: "Debug")
|
||||
}
|
||||
|
|
@ -139,9 +137,9 @@ extension JSContext {
|
|||
Logger.shared.log("Redirect value is \(redirect.boolValue)", type: "Error")
|
||||
let session = URLSession.fetchData(allowRedirects: redirect.boolValue)
|
||||
|
||||
let task = session.downloadTask(with: request) { tempFileURL, response, error in
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
let task = session.downloadTask(with: request) { tempFileURL, response, error in
|
||||
defer { session.finishTasksAndInvalidate() }
|
||||
|
||||
let callReject: (String) -> Void = { message in
|
||||
DispatchQueue.main.async {
|
||||
reject.call(withArguments: [message])
|
||||
|
|
@ -276,6 +274,7 @@ extension JSContext {
|
|||
}
|
||||
|
||||
func setupJavaScriptEnvironment() {
|
||||
setupWeirdCode()
|
||||
setupConsoleLogging()
|
||||
setupNativeFetch()
|
||||
setupFetchV2()
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
class FetchDelegate: NSObject, URLSessionTaskDelegate {
|
||||
private let allowRedirects: Bool
|
||||
|
|
@ -27,29 +28,29 @@ class FetchDelegate: NSObject, URLSessionTaskDelegate {
|
|||
|
||||
extension URLSession {
|
||||
static let userAgents = [
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.2569.45",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.2478.89",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.2903.86",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.2849.80",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.2 Safari/605.1.15",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15_1_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.1 Safari/605.1.15",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15_0_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.0 Safari/605.1.15",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15.1; rv:128.0) Gecko/20100101 Firefox/128.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15.0; rv:127.0) Gecko/20100101 Firefox/127.0",
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0",
|
||||
"Mozilla/5.0 (Linux; Android 15; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6782.112 Mobile Safari/537.36",
|
||||
"Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6782.112 Mobile Safari/537.36",
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.3 Mobile/15E148 Safari/604.1",
|
||||
"Mozilla/5.0 (iPad; CPU OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.3 Mobile/15E148 Safari/604.1",
|
||||
"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"
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15.1; rv:132.0) Gecko/20100101 Firefox/132.0",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 15.0; rv:131.0) Gecko/20100101 Firefox/131.0",
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:132.0) Gecko/20100101 Firefox/132.0",
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0",
|
||||
"Mozilla/5.0 (Linux; Android 15; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.135 Mobile Safari/537.36",
|
||||
"Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.135 Mobile Safari/537.36",
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.2 Mobile/15E148 Safari/604.1",
|
||||
"Mozilla/5.0 (iPad; CPU OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.2 Mobile/15E148 Safari/604.1",
|
||||
"Mozilla/5.0 (Android 15; Mobile; rv:132.0) Gecko/132.0 Firefox/132.0",
|
||||
"Mozilla/5.0 (Android 14; Mobile; rv:131.0) Gecko/131.0 Firefox/131.0"
|
||||
]
|
||||
|
||||
static var randomUserAgent: String = {
|
||||
|
|
@ -70,3 +71,51 @@ extension URLSession {
|
|||
return URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil)
|
||||
}
|
||||
}
|
||||
|
||||
enum NetworkType {
|
||||
case wifi
|
||||
case cellular
|
||||
case unknown
|
||||
}
|
||||
|
||||
class NetworkMonitor: ObservableObject {
|
||||
static let shared = NetworkMonitor()
|
||||
|
||||
private let monitor = NWPathMonitor()
|
||||
private let queue = DispatchQueue(label: "NetworkMonitor")
|
||||
|
||||
@Published var currentNetworkType: NetworkType = .unknown
|
||||
@Published var isConnected: Bool = false
|
||||
|
||||
private init() {
|
||||
startMonitoring()
|
||||
}
|
||||
|
||||
private func startMonitoring() {
|
||||
monitor.pathUpdateHandler = { [weak self] path in
|
||||
DispatchQueue.main.async {
|
||||
self?.isConnected = path.status == .satisfied
|
||||
self?.currentNetworkType = self?.getNetworkType(from: path) ?? .unknown
|
||||
}
|
||||
}
|
||||
monitor.start(queue: queue)
|
||||
}
|
||||
|
||||
private func getNetworkType(from path: NWPath) -> NetworkType {
|
||||
if path.usesInterfaceType(.wifi) {
|
||||
return .wifi
|
||||
} else if path.usesInterfaceType(.cellular) {
|
||||
return .cellular
|
||||
} else {
|
||||
return .unknown
|
||||
}
|
||||
}
|
||||
|
||||
static func getCurrentNetworkType() -> NetworkType {
|
||||
return shared.currentNetworkType
|
||||
}
|
||||
|
||||
deinit {
|
||||
monitor.cancel()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,63 @@
|
|||
|
||||
import UIKit
|
||||
|
||||
enum VideoQualityPreference: String, CaseIterable {
|
||||
case best = "Best"
|
||||
case p1080 = "1080p"
|
||||
case p720 = "720p"
|
||||
case p420 = "420p"
|
||||
case p360 = "360p"
|
||||
case worst = "Worst"
|
||||
|
||||
static let wifiDefaultKey = "videoQualityWiFi"
|
||||
static let cellularDefaultKey = "videoQualityCellular"
|
||||
|
||||
static let defaultWiFiPreference: VideoQualityPreference = .best
|
||||
static let defaultCellularPreference: VideoQualityPreference = .p720
|
||||
|
||||
static let qualityPriority: [VideoQualityPreference] = [.best, .p1080, .p720, .p420, .p360, .worst]
|
||||
|
||||
static func findClosestQuality(preferred: VideoQualityPreference, availableQualities: [(String, String)]) -> (String, String)? {
|
||||
for (name, url) in availableQualities {
|
||||
if isQualityMatch(preferred: preferred, qualityName: name) {
|
||||
return (name, url)
|
||||
}
|
||||
}
|
||||
|
||||
let preferredIndex = qualityPriority.firstIndex(of: preferred) ?? qualityPriority.count
|
||||
|
||||
for i in 0..<qualityPriority.count {
|
||||
let candidate = qualityPriority[i]
|
||||
for (name, url) in availableQualities {
|
||||
if isQualityMatch(preferred: candidate, qualityName: name) {
|
||||
return (name, url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return availableQualities.first
|
||||
}
|
||||
|
||||
private static func isQualityMatch(preferred: VideoQualityPreference, qualityName: String) -> Bool {
|
||||
let lowercaseName = qualityName.lowercased()
|
||||
|
||||
switch preferred {
|
||||
case .best:
|
||||
return lowercaseName.contains("best") || lowercaseName.contains("highest") || lowercaseName.contains("max")
|
||||
case .p1080:
|
||||
return lowercaseName.contains("1080") || lowercaseName.contains("1920")
|
||||
case .p720:
|
||||
return lowercaseName.contains("720") || lowercaseName.contains("1280")
|
||||
case .p420:
|
||||
return lowercaseName.contains("420") || lowercaseName.contains("480")
|
||||
case .p360:
|
||||
return lowercaseName.contains("360") || lowercaseName.contains("640")
|
||||
case .worst:
|
||||
return lowercaseName.contains("worst") || lowercaseName.contains("lowest") || lowercaseName.contains("min")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension UserDefaults {
|
||||
func color(forKey key: String) -> UIColor? {
|
||||
guard let colorData = data(forKey: key) else { return nil }
|
||||
|
|
@ -30,4 +87,19 @@ extension UserDefaults {
|
|||
Logger.shared.log("Error archiving color: \(error)", type: "Error")
|
||||
}
|
||||
}
|
||||
|
||||
static func getVideoQualityPreference() -> VideoQualityPreference {
|
||||
let networkType = NetworkMonitor.getCurrentNetworkType()
|
||||
|
||||
switch networkType {
|
||||
case .wifi:
|
||||
let rawValue = UserDefaults.standard.string(forKey: VideoQualityPreference.wifiDefaultKey)
|
||||
return VideoQualityPreference(rawValue: rawValue ?? "") ?? VideoQualityPreference.defaultWiFiPreference
|
||||
case .cellular:
|
||||
let rawValue = UserDefaults.standard.string(forKey: VideoQualityPreference.cellularDefaultKey)
|
||||
return VideoQualityPreference(rawValue: rawValue ?? "") ?? VideoQualityPreference.defaultCellularPreference
|
||||
case .unknown:
|
||||
return .p720
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
527
Sora/Utils/JSLoader/Downloads/JSController+Downloader.swift
Normal file
527
Sora/Utils/JSLoader/Downloads/JSController+Downloader.swift
Normal file
|
|
@ -0,0 +1,527 @@
|
|||
//
|
||||
// JSController+Downloader.swift
|
||||
// Sora
|
||||
//
|
||||
// Created by doomsboygaming on 6/13/25
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
|
||||
|
||||
struct DownloadRequest {
|
||||
let url: URL
|
||||
let headers: [String: String]
|
||||
let title: String?
|
||||
let imageURL: URL?
|
||||
let isEpisode: Bool
|
||||
let showTitle: String?
|
||||
let season: Int?
|
||||
let episode: Int?
|
||||
let subtitleURL: URL?
|
||||
let showPosterURL: URL?
|
||||
|
||||
init(url: URL, headers: [String: String], title: String? = nil, imageURL: URL? = nil,
|
||||
isEpisode: Bool = false, showTitle: String? = nil, season: Int? = nil,
|
||||
episode: Int? = nil, subtitleURL: URL? = nil, showPosterURL: URL? = nil) {
|
||||
self.url = url
|
||||
self.headers = headers
|
||||
self.title = title
|
||||
self.imageURL = imageURL
|
||||
self.isEpisode = isEpisode
|
||||
self.showTitle = showTitle
|
||||
self.season = season
|
||||
self.episode = episode
|
||||
self.subtitleURL = subtitleURL
|
||||
self.showPosterURL = showPosterURL
|
||||
}
|
||||
}
|
||||
|
||||
struct QualityOption {
|
||||
let name: String
|
||||
let url: String
|
||||
let height: Int?
|
||||
|
||||
init(name: String, url: String, height: Int? = nil) {
|
||||
self.name = name
|
||||
self.url = url
|
||||
self.height = height
|
||||
}
|
||||
}
|
||||
|
||||
extension JSController {
|
||||
|
||||
func downloadWithM3U8Support(url: URL, headers: [String: String], title: String? = nil,
|
||||
imageURL: URL? = nil, isEpisode: Bool = false,
|
||||
showTitle: String? = nil, season: Int? = nil, episode: Int? = nil,
|
||||
subtitleURL: URL? = nil, showPosterURL: URL? = nil,
|
||||
completionHandler: ((Bool, String) -> Void)? = nil) {
|
||||
|
||||
let request = DownloadRequest(
|
||||
url: url, headers: headers, title: title, imageURL: imageURL,
|
||||
isEpisode: isEpisode, showTitle: showTitle, season: season,
|
||||
episode: episode, subtitleURL: subtitleURL, showPosterURL: showPosterURL
|
||||
)
|
||||
|
||||
logDownloadStart(request: request)
|
||||
|
||||
if url.absoluteString.contains(".m3u8") {
|
||||
handleM3U8Download(request: request, completionHandler: completionHandler)
|
||||
} else {
|
||||
handleDirectDownload(request: request, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func handleM3U8Download(request: DownloadRequest, completionHandler: ((Bool, String) -> Void)?) {
|
||||
let preferredQuality = DownloadQualityPreference.current.rawValue
|
||||
logM3U8Detection(preferredQuality: preferredQuality)
|
||||
|
||||
parseM3U8(url: request.url, headers: request.headers) { [weak self] qualities in
|
||||
DispatchQueue.main.async {
|
||||
guard let self = self else { return }
|
||||
|
||||
if qualities.isEmpty {
|
||||
self.logM3U8NoQualities()
|
||||
self.downloadWithOriginalMethod(request: request, completionHandler: completionHandler)
|
||||
return
|
||||
}
|
||||
|
||||
self.logM3U8QualitiesFound(qualities: qualities)
|
||||
let selectedQuality = self.selectQualityBasedOnPreference(qualities: qualities, preferredQuality: preferredQuality)
|
||||
self.logM3U8QualitySelected(quality: selectedQuality)
|
||||
|
||||
if let qualityURL = URL(string: selectedQuality.url) {
|
||||
let qualityRequest = DownloadRequest(
|
||||
url: qualityURL, headers: request.headers, title: request.title,
|
||||
imageURL: request.imageURL, isEpisode: request.isEpisode,
|
||||
showTitle: request.showTitle, season: request.season,
|
||||
episode: request.episode, subtitleURL: request.subtitleURL,
|
||||
showPosterURL: request.showPosterURL
|
||||
)
|
||||
self.downloadWithOriginalMethod(request: qualityRequest, completionHandler: completionHandler)
|
||||
} else {
|
||||
self.logM3U8InvalidURL()
|
||||
self.downloadWithOriginalMethod(request: request, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDirectDownload(request: DownloadRequest, completionHandler: ((Bool, String) -> Void)?) {
|
||||
logDirectDownload()
|
||||
|
||||
let urlString = request.url.absoluteString.lowercased()
|
||||
if urlString.contains(".mp4") || urlString.contains("mp4") {
|
||||
logMP4Detection()
|
||||
downloadMP4(request: request, completionHandler: completionHandler)
|
||||
} else {
|
||||
downloadWithOriginalMethod(request: request, completionHandler: completionHandler)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func downloadMP4(url: URL, headers: [String: String], title: String? = nil,
|
||||
imageURL: URL? = nil, isEpisode: Bool = false,
|
||||
showTitle: String? = nil, season: Int? = nil, episode: Int? = nil,
|
||||
subtitleURL: URL? = nil, showPosterURL: URL? = nil,
|
||||
completionHandler: ((Bool, String) -> Void)? = nil) {
|
||||
|
||||
let request = DownloadRequest(
|
||||
url: url, headers: headers, title: title, imageURL: imageURL,
|
||||
isEpisode: isEpisode, showTitle: showTitle, season: season,
|
||||
episode: episode, subtitleURL: subtitleURL, showPosterURL: showPosterURL
|
||||
)
|
||||
|
||||
downloadMP4(request: request, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
private func downloadMP4(request: DownloadRequest, completionHandler: ((Bool, String) -> Void)?) {
|
||||
guard validateURL(request.url) else {
|
||||
completionHandler?(false, "Invalid URL scheme")
|
||||
return
|
||||
}
|
||||
|
||||
guard let downloadSession = downloadURLSession else {
|
||||
completionHandler?(false, "Download session not available")
|
||||
return
|
||||
}
|
||||
|
||||
let metadata = createAssetMetadata(from: request)
|
||||
let downloadType: DownloadType = request.isEpisode ? .episode : .movie
|
||||
let downloadID = UUID()
|
||||
|
||||
let asset = AVURLAsset(url: request.url, options: [
|
||||
"AVURLAssetHTTPHeaderFieldsKey": request.headers
|
||||
])
|
||||
|
||||
guard let downloadTask = downloadSession.makeAssetDownloadTask(
|
||||
asset: asset,
|
||||
assetTitle: request.title ?? request.url.lastPathComponent,
|
||||
assetArtworkData: nil,
|
||||
options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 2_000_000]
|
||||
) else {
|
||||
completionHandler?(false, "Failed to create download task")
|
||||
return
|
||||
}
|
||||
|
||||
let activeDownload = createActiveDownload(
|
||||
id: downloadID, request: request, asset: asset,
|
||||
downloadTask: downloadTask, downloadType: downloadType, metadata: metadata
|
||||
)
|
||||
|
||||
addActiveDownload(activeDownload, task: downloadTask)
|
||||
setupMP4ProgressObservation(for: downloadTask, downloadID: downloadID)
|
||||
downloadTask.resume()
|
||||
|
||||
postDownloadNotification()
|
||||
completionHandler?(true, "Download started")
|
||||
}
|
||||
|
||||
|
||||
private func parseM3U8(url: URL, headers: [String: String], completion: @escaping ([QualityOption]) -> Void) {
|
||||
var request = URLRequest(url: url)
|
||||
for (key, value) in headers {
|
||||
request.addValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
logM3U8FetchStart(url: url)
|
||||
|
||||
URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
self.logHTTPStatus(httpResponse.statusCode, for: url)
|
||||
if httpResponse.statusCode >= 400 {
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let error = error {
|
||||
self.logM3U8FetchError(error)
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data, let content = String(data: data, encoding: .utf8) else {
|
||||
self.logM3U8DecodeError()
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
|
||||
self.logM3U8FetchSuccess(dataSize: data.count)
|
||||
let qualities = self.parseM3U8Content(content: content, baseURL: url)
|
||||
completion(qualities)
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private func parseM3U8Content(content: String, baseURL: URL) -> [QualityOption] {
|
||||
let lines = content.components(separatedBy: .newlines)
|
||||
logM3U8ParseStart(lineCount: lines.count)
|
||||
|
||||
var qualities: [QualityOption] = []
|
||||
qualities.append(QualityOption(name: "Auto (Recommended)", url: baseURL.absoluteString))
|
||||
|
||||
for (index, line) in lines.enumerated() {
|
||||
if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count {
|
||||
if let qualityOption = parseStreamInfoLine(line: line, nextLine: lines[index + 1], baseURL: baseURL) {
|
||||
if !qualities.contains(where: { $0.name == qualityOption.name }) {
|
||||
qualities.append(qualityOption)
|
||||
logM3U8QualityAdded(quality: qualityOption)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logM3U8ParseComplete(qualityCount: qualities.count - 1) // -1 for Auto
|
||||
return qualities
|
||||
}
|
||||
|
||||
private func parseStreamInfoLine(line: String, nextLine: String, baseURL: URL) -> QualityOption? {
|
||||
guard let resolutionRange = line.range(of: "RESOLUTION="),
|
||||
let resolutionEndRange = line[resolutionRange.upperBound...].range(of: ",")
|
||||
?? line[resolutionRange.upperBound...].range(of: "\n") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let resolutionPart = String(line[resolutionRange.upperBound..<resolutionEndRange.lowerBound])
|
||||
guard let heightStr = resolutionPart.components(separatedBy: "x").last,
|
||||
let height = Int(heightStr) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let qualityName = getQualityName(for: height)
|
||||
let qualityURL = resolveQualityURL(nextLine.trimmingCharacters(in: .whitespacesAndNewlines), baseURL: baseURL)
|
||||
|
||||
return QualityOption(name: qualityName, url: qualityURL, height: height)
|
||||
}
|
||||
|
||||
private func getQualityName(for height: Int) -> String {
|
||||
switch height {
|
||||
case 1080...: return "\(height)p (FHD)"
|
||||
case 720..<1080: return "\(height)p (HD)"
|
||||
case 480..<720: return "\(height)p (SD)"
|
||||
default: return "\(height)p"
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveQualityURL(_ urlString: String, baseURL: URL) -> String {
|
||||
if urlString.hasPrefix("http") {
|
||||
return urlString
|
||||
}
|
||||
|
||||
if urlString.contains(".m3u8") {
|
||||
return URL(string: urlString, relativeTo: baseURL)?.absoluteString
|
||||
?? baseURL.deletingLastPathComponent().absoluteString + "/" + urlString
|
||||
}
|
||||
|
||||
return urlString
|
||||
}
|
||||
|
||||
|
||||
private func selectQualityBasedOnPreference(qualities: [QualityOption], preferredQuality: String) -> QualityOption {
|
||||
guard qualities.count > 1 else {
|
||||
logQualitySelectionSingle()
|
||||
return qualities[0]
|
||||
}
|
||||
|
||||
let (autoQuality, sortedQualities) = categorizeQualities(qualities: qualities)
|
||||
logQualitySelectionStart(preference: preferredQuality, sortedCount: sortedQualities.count)
|
||||
|
||||
let selected = selectQualityByPreference(
|
||||
preference: preferredQuality,
|
||||
sortedQualities: sortedQualities,
|
||||
autoQuality: autoQuality,
|
||||
fallback: qualities[0]
|
||||
)
|
||||
|
||||
logQualitySelectionResult(quality: selected, preference: preferredQuality)
|
||||
return selected
|
||||
}
|
||||
|
||||
private func categorizeQualities(qualities: [QualityOption]) -> (auto: QualityOption?, sorted: [QualityOption]) {
|
||||
let autoQuality = qualities.first { $0.name.contains("Auto") }
|
||||
let nonAutoQualities = qualities.filter { !$0.name.contains("Auto") }
|
||||
|
||||
let sortedQualities = nonAutoQualities.sorted { first, second in
|
||||
let firstHeight = first.height ?? extractHeight(from: first.name)
|
||||
let secondHeight = second.height ?? extractHeight(from: second.name)
|
||||
return firstHeight > secondHeight
|
||||
}
|
||||
|
||||
return (autoQuality, sortedQualities)
|
||||
}
|
||||
|
||||
private func selectQualityByPreference(preference: String, sortedQualities: [QualityOption],
|
||||
autoQuality: QualityOption?, fallback: QualityOption) -> QualityOption {
|
||||
switch preference {
|
||||
case "Best":
|
||||
return sortedQualities.first ?? fallback
|
||||
case "High":
|
||||
return findQualityByType(["720p", "HD"], in: sortedQualities) ?? sortedQualities.first ?? fallback
|
||||
case "Medium":
|
||||
return findQualityByType(["480p", "SD"], in: sortedQualities)
|
||||
?? (sortedQualities.isEmpty ? fallback : sortedQualities[sortedQualities.count / 2])
|
||||
case "Low":
|
||||
return sortedQualities.last ?? fallback
|
||||
default:
|
||||
return autoQuality ?? fallback
|
||||
}
|
||||
}
|
||||
|
||||
private func findQualityByType(_ types: [String], in qualities: [QualityOption]) -> QualityOption? {
|
||||
return qualities.first { quality in
|
||||
types.contains { quality.name.contains($0) }
|
||||
}
|
||||
}
|
||||
|
||||
private func extractHeight(from qualityName: String) -> Int {
|
||||
return Int(qualityName.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0
|
||||
}
|
||||
|
||||
|
||||
private func validateURL(_ url: URL) -> Bool {
|
||||
return url.scheme == "http" || url.scheme == "https"
|
||||
}
|
||||
|
||||
private func createAssetMetadata(from request: DownloadRequest) -> AssetMetadata? {
|
||||
guard let title = request.title else { return nil }
|
||||
|
||||
return AssetMetadata(
|
||||
title: title,
|
||||
posterURL: request.imageURL,
|
||||
showTitle: request.showTitle,
|
||||
season: request.season,
|
||||
episode: request.episode,
|
||||
showPosterURL: request.showPosterURL ?? request.imageURL
|
||||
)
|
||||
}
|
||||
|
||||
private func createActiveDownload(id: UUID, request: DownloadRequest, asset: AVURLAsset,
|
||||
downloadTask: AVAssetDownloadTask? = nil, urlSessionTask: URLSessionDownloadTask? = nil,
|
||||
downloadType: DownloadType, metadata: AssetMetadata?) -> JSActiveDownload {
|
||||
return JSActiveDownload(
|
||||
id: id,
|
||||
originalURL: request.url,
|
||||
progress: 0.0,
|
||||
task: downloadTask,
|
||||
urlSessionTask: urlSessionTask,
|
||||
queueStatus: .downloading,
|
||||
type: downloadType,
|
||||
metadata: metadata,
|
||||
title: request.title,
|
||||
imageURL: request.imageURL,
|
||||
subtitleURL: request.subtitleURL,
|
||||
asset: asset,
|
||||
headers: request.headers,
|
||||
module: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func addActiveDownload(_ download: JSActiveDownload, task: URLSessionTask) {
|
||||
activeDownloads.append(download)
|
||||
activeDownloadMap[task] = download.id
|
||||
}
|
||||
|
||||
private func postDownloadNotification() {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("downloadStatusChanged"), object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
private func downloadWithOriginalMethod(request: DownloadRequest, completionHandler: ((Bool, String) -> Void)?) {
|
||||
self.startDownload(
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
title: request.title,
|
||||
imageURL: request.imageURL,
|
||||
isEpisode: request.isEpisode,
|
||||
showTitle: request.showTitle,
|
||||
season: request.season,
|
||||
episode: request.episode,
|
||||
subtitleURL: request.subtitleURL,
|
||||
showPosterURL: request.showPosterURL,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
private func setupMP4ProgressObservation(for task: AVAssetDownloadTask, downloadID: UUID) {
|
||||
let observation = task.progress.observe(\.fractionCompleted, options: [.new]) { [weak self] progress, _ in
|
||||
DispatchQueue.main.async {
|
||||
guard let self = self else { return }
|
||||
self.updateMP4DownloadProgress(task: task, progress: progress.fractionCompleted)
|
||||
NotificationCenter.default.post(name: NSNotification.Name("downloadProgressChanged"), object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
if mp4ProgressObservations == nil {
|
||||
mp4ProgressObservations = [:]
|
||||
}
|
||||
mp4ProgressObservations?[downloadID] = observation
|
||||
}
|
||||
|
||||
private func updateMP4DownloadProgress(task: AVAssetDownloadTask, progress: Double) {
|
||||
guard let downloadID = activeDownloadMap[task],
|
||||
let downloadIndex = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
|
||||
return
|
||||
}
|
||||
activeDownloads[downloadIndex].progress = progress
|
||||
}
|
||||
|
||||
func cleanupMP4ProgressObservation(for downloadID: UUID) {
|
||||
mp4ProgressObservations?[downloadID]?.invalidate()
|
||||
mp4ProgressObservations?[downloadID] = nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
extension JSController {
|
||||
private func logDownloadStart(request: DownloadRequest) {
|
||||
Logger.shared.log("Download process started for URL: \(request.url.absoluteString)", type: "Download")
|
||||
Logger.shared.log("Title: \(request.title ?? "None"), Episode: \(request.isEpisode ? "Yes" : "No")", type: "Debug")
|
||||
if let showTitle = request.showTitle, let episode = request.episode {
|
||||
Logger.shared.log("Show: \(showTitle), Season: \(request.season ?? 1), Episode: \(episode)", type: "Debug")
|
||||
}
|
||||
if let subtitle = request.subtitleURL {
|
||||
Logger.shared.log("Subtitle URL provided: \(subtitle.absoluteString)", type: "Debug")
|
||||
}
|
||||
}
|
||||
|
||||
private func logM3U8Detection(preferredQuality: String) {
|
||||
Logger.shared.log("M3U8 playlist detected - quality preference: \(preferredQuality)", type: "Download")
|
||||
}
|
||||
|
||||
private func logM3U8NoQualities() {
|
||||
Logger.shared.log("No quality options found in M3U8, using original URL", type: "Warning")
|
||||
}
|
||||
|
||||
private func logM3U8QualitiesFound(qualities: [QualityOption]) {
|
||||
Logger.shared.log("Found \(qualities.count) quality options in M3U8 playlist", type: "Download")
|
||||
for (index, quality) in qualities.enumerated() {
|
||||
Logger.shared.log("Quality \(index + 1): \(quality.name)", type: "Debug")
|
||||
}
|
||||
}
|
||||
|
||||
private func logM3U8QualitySelected(quality: QualityOption) {
|
||||
Logger.shared.log("Selected quality: \(quality.name)", type: "Download")
|
||||
Logger.shared.log("Final download URL: \(quality.url)", type: "Debug")
|
||||
}
|
||||
|
||||
private func logM3U8InvalidURL() {
|
||||
Logger.shared.log("Invalid quality URL detected, falling back to original", type: "Warning")
|
||||
}
|
||||
|
||||
private func logDirectDownload() {
|
||||
Logger.shared.log("Direct download initiated (non-M3U8)", type: "Download")
|
||||
}
|
||||
|
||||
private func logMP4Detection() {
|
||||
Logger.shared.log("MP4 stream detected, using MP4 download method", type: "Download")
|
||||
}
|
||||
|
||||
private func logM3U8FetchStart(url: URL) {
|
||||
Logger.shared.log("Fetching M3U8 content from: \(url.absoluteString)", type: "Debug")
|
||||
}
|
||||
|
||||
private func logHTTPStatus(_ statusCode: Int, for url: URL) {
|
||||
let logType = statusCode >= 400 ? "Error" : "Debug"
|
||||
Logger.shared.log("HTTP \(statusCode) for M3U8 request: \(url.absoluteString)", type: logType)
|
||||
}
|
||||
|
||||
private func logM3U8FetchError(_ error: Error) {
|
||||
Logger.shared.log("Failed to fetch M3U8 content: \(error.localizedDescription)", type: "Error")
|
||||
}
|
||||
|
||||
private func logM3U8DecodeError() {
|
||||
Logger.shared.log("Failed to decode M3U8 file content", type: "Error")
|
||||
}
|
||||
|
||||
private func logM3U8FetchSuccess(dataSize: Int) {
|
||||
Logger.shared.log("Successfully fetched M3U8 content (\(dataSize) bytes)", type: "Debug")
|
||||
}
|
||||
|
||||
private func logM3U8ParseStart(lineCount: Int) {
|
||||
Logger.shared.log("Parsing M3U8 file with \(lineCount) lines", type: "Debug")
|
||||
}
|
||||
|
||||
private func logM3U8QualityAdded(quality: QualityOption) {
|
||||
Logger.shared.log("Added quality option: \(quality.name)", type: "Debug")
|
||||
}
|
||||
|
||||
private func logM3U8ParseComplete(qualityCount: Int) {
|
||||
Logger.shared.log("M3U8 parsing complete: \(qualityCount) quality options found", type: "Debug")
|
||||
}
|
||||
|
||||
private func logQualitySelectionSingle() {
|
||||
Logger.shared.log("Only one quality available, using default", type: "Debug")
|
||||
}
|
||||
|
||||
private func logQualitySelectionStart(preference: String, sortedCount: Int) {
|
||||
Logger.shared.log("Quality selection: \(sortedCount) options, preference: \(preference)", type: "Debug")
|
||||
}
|
||||
|
||||
private func logQualitySelectionResult(quality: QualityOption, preference: String) {
|
||||
Logger.shared.log("Quality selected: \(quality.name) (preference: \(preference))", type: "Download")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,384 +0,0 @@
|
|||
//
|
||||
// JSController+M3U8Download.swift
|
||||
// Sora
|
||||
//
|
||||
// Created by doomsboygaming on 5/22/25
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
// No need to import DownloadQualityPreference as it's in the same module
|
||||
|
||||
// Extension for integrating M3U8StreamExtractor with JSController for downloads
|
||||
extension JSController {
|
||||
|
||||
/// Initiates a download for a given URL, handling M3U8 playlists if necessary
|
||||
/// - Parameters:
|
||||
/// - url: The URL to download
|
||||
/// - headers: HTTP headers to use for the request
|
||||
/// - title: Title for the download (optional)
|
||||
/// - imageURL: Image URL for the content (optional)
|
||||
/// - isEpisode: Whether this is an episode (defaults to false)
|
||||
/// - showTitle: Title of the show this episode belongs to (optional)
|
||||
/// - season: Season number (optional)
|
||||
/// - episode: Episode number (optional)
|
||||
/// - subtitleURL: Optional subtitle URL to download after video (optional)
|
||||
/// - completionHandler: Called when the download is initiated or fails
|
||||
func downloadWithM3U8Support(url: URL, headers: [String: String], title: String? = nil,
|
||||
imageURL: URL? = nil, isEpisode: Bool = false,
|
||||
showTitle: String? = nil, season: Int? = nil, episode: Int? = nil,
|
||||
subtitleURL: URL? = nil, showPosterURL: URL? = nil,
|
||||
completionHandler: ((Bool, String) -> Void)? = nil) {
|
||||
// Use headers passed in from caller rather than generating our own baseUrl
|
||||
// Receiving code should already be setting module.metadata.baseUrl
|
||||
|
||||
print("---- DOWNLOAD PROCESS STARTED ----")
|
||||
print("Original URL: \(url.absoluteString)")
|
||||
print("Headers: \(headers)")
|
||||
print("Title: \(title ?? "None")")
|
||||
print("Is Episode: \(isEpisode), Show: \(showTitle ?? "None"), Season: \(season?.description ?? "None"), Episode: \(episode?.description ?? "None")")
|
||||
if let subtitle = subtitleURL {
|
||||
print("Subtitle URL: \(subtitle.absoluteString)")
|
||||
}
|
||||
|
||||
// Check if the URL is an M3U8 file
|
||||
if url.absoluteString.contains(".m3u8") {
|
||||
// Get the user's quality preference
|
||||
let preferredQuality = DownloadQualityPreference.current.rawValue
|
||||
|
||||
print("URL detected as M3U8 playlist - will select quality based on user preference: \(preferredQuality)")
|
||||
|
||||
// Parse the M3U8 content to extract available qualities, matching CustomPlayer approach
|
||||
parseM3U8(url: url, baseUrl: url.absoluteString, headers: headers) { [weak self] qualities in
|
||||
DispatchQueue.main.async {
|
||||
guard let self = self else { return }
|
||||
|
||||
if qualities.isEmpty {
|
||||
print("M3U8 Analysis: No quality options found in M3U8, downloading with original URL")
|
||||
self.downloadWithOriginalMethod(
|
||||
url: url,
|
||||
headers: headers,
|
||||
title: title,
|
||||
imageURL: imageURL,
|
||||
isEpisode: isEpisode,
|
||||
showTitle: showTitle,
|
||||
season: season,
|
||||
episode: episode,
|
||||
subtitleURL: subtitleURL,
|
||||
showPosterURL: showPosterURL,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
print("M3U8 Analysis: Found \(qualities.count) quality options")
|
||||
for (index, quality) in qualities.enumerated() {
|
||||
print(" \(index + 1). \(quality.0) - \(quality.1)")
|
||||
}
|
||||
|
||||
// Select appropriate quality based on user preference
|
||||
let selectedQuality = self.selectQualityBasedOnPreference(qualities: qualities, preferredQuality: preferredQuality)
|
||||
|
||||
print("M3U8 Analysis: Selected quality: \(selectedQuality.0)")
|
||||
print("M3U8 Analysis: Selected URL: \(selectedQuality.1)")
|
||||
|
||||
if let qualityURL = URL(string: selectedQuality.1) {
|
||||
print("FINAL DOWNLOAD URL: \(qualityURL.absoluteString)")
|
||||
print("QUALITY SELECTED: \(selectedQuality.0)")
|
||||
|
||||
// Download with standard headers that match the player
|
||||
self.downloadWithOriginalMethod(
|
||||
url: qualityURL,
|
||||
headers: headers,
|
||||
title: title,
|
||||
imageURL: imageURL,
|
||||
isEpisode: isEpisode,
|
||||
showTitle: showTitle,
|
||||
season: season,
|
||||
episode: episode,
|
||||
subtitleURL: subtitleURL,
|
||||
showPosterURL: showPosterURL,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
} else {
|
||||
print("M3U8 Analysis: Invalid quality URL, falling back to original URL")
|
||||
print("FINAL DOWNLOAD URL (fallback): \(url.absoluteString)")
|
||||
|
||||
self.downloadWithOriginalMethod(
|
||||
url: url,
|
||||
headers: headers,
|
||||
title: title,
|
||||
imageURL: imageURL,
|
||||
isEpisode: isEpisode,
|
||||
showTitle: showTitle,
|
||||
season: season,
|
||||
episode: episode,
|
||||
subtitleURL: subtitleURL,
|
||||
showPosterURL: showPosterURL,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not an M3U8 file, use the original download method with standard headers
|
||||
print("URL is not an M3U8 playlist - downloading directly")
|
||||
print("FINAL DOWNLOAD URL (direct): \(url.absoluteString)")
|
||||
|
||||
downloadWithOriginalMethod(
|
||||
url: url,
|
||||
headers: headers,
|
||||
title: title,
|
||||
imageURL: imageURL,
|
||||
isEpisode: isEpisode,
|
||||
showTitle: showTitle,
|
||||
season: season,
|
||||
episode: episode,
|
||||
subtitleURL: subtitleURL,
|
||||
showPosterURL: showPosterURL,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses an M3U8 file to extract available quality options, matching CustomPlayer's approach exactly
|
||||
/// - Parameters:
|
||||
/// - url: The URL of the M3U8 file
|
||||
/// - baseUrl: The base URL for setting headers
|
||||
/// - headers: HTTP headers to use for the request
|
||||
/// - completion: Called with the array of quality options (name, URL)
|
||||
private func parseM3U8(url: URL, baseUrl: String, headers: [String: String], completion: @escaping ([(String, String)]) -> Void) {
|
||||
var request = URLRequest(url: url)
|
||||
|
||||
// Add headers from headers passed to downloadWithM3U8Support
|
||||
// This ensures we use the same headers as the player (from module.metadata.baseUrl)
|
||||
for (key, value) in headers {
|
||||
request.addValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
|
||||
print("M3U8 Parser: Fetching M3U8 content from: \(url.absoluteString)")
|
||||
|
||||
URLSession.shared.dataTask(with: request) { data, response, error in
|
||||
// Log HTTP status for debugging
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
print("M3U8 Parser: HTTP Status: \(httpResponse.statusCode) for \(url.absoluteString)")
|
||||
|
||||
if httpResponse.statusCode >= 400 {
|
||||
print("M3U8 Parser: HTTP Error: \(httpResponse.statusCode)")
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if let error = error {
|
||||
print("M3U8 Parser: Error fetching M3U8: \(error.localizedDescription)")
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
|
||||
guard let data = data, let content = String(data: data, encoding: .utf8) else {
|
||||
print("M3U8 Parser: Failed to load or decode M3U8 file")
|
||||
completion([])
|
||||
return
|
||||
}
|
||||
|
||||
print("M3U8 Parser: Successfully fetched M3U8 content (\(data.count) bytes)")
|
||||
|
||||
let lines = content.components(separatedBy: .newlines)
|
||||
print("M3U8 Parser: Found \(lines.count) lines in M3U8 file")
|
||||
|
||||
var qualities: [(String, String)] = []
|
||||
|
||||
// Always include the original URL as "Auto" option
|
||||
qualities.append(("Auto (Recommended)", url.absoluteString))
|
||||
print("M3U8 Parser: Added 'Auto' quality option with original URL")
|
||||
|
||||
func getQualityName(for height: Int) -> String {
|
||||
switch height {
|
||||
case 1080...: return "\(height)p (FHD)"
|
||||
case 720..<1080: return "\(height)p (HD)"
|
||||
case 480..<720: return "\(height)p (SD)"
|
||||
default: return "\(height)p"
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the M3U8 content to extract available streams - exactly like CustomPlayer
|
||||
print("M3U8 Parser: Scanning for quality options...")
|
||||
var qualitiesFound = 0
|
||||
|
||||
for (index, line) in lines.enumerated() {
|
||||
if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count {
|
||||
print("M3U8 Parser: Found stream info at line \(index): \(line)")
|
||||
|
||||
if let resolutionRange = line.range(of: "RESOLUTION="),
|
||||
let resolutionEndRange = line[resolutionRange.upperBound...].range(of: ",")
|
||||
?? line[resolutionRange.upperBound...].range(of: "\n") {
|
||||
|
||||
let resolutionPart = String(line[resolutionRange.upperBound..<resolutionEndRange.lowerBound])
|
||||
print("M3U8 Parser: Extracted resolution: \(resolutionPart)")
|
||||
|
||||
if let heightStr = resolutionPart.components(separatedBy: "x").last,
|
||||
let height = Int(heightStr) {
|
||||
|
||||
let nextLine = lines[index + 1].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let qualityName = getQualityName(for: height)
|
||||
|
||||
print("M3U8 Parser: Found height \(height)px, quality name: \(qualityName)")
|
||||
print("M3U8 Parser: Stream URL from next line: \(nextLine)")
|
||||
|
||||
var qualityURL = nextLine
|
||||
if !nextLine.hasPrefix("http") && nextLine.contains(".m3u8") {
|
||||
// Handle relative URLs
|
||||
let baseURLString = url.deletingLastPathComponent().absoluteString
|
||||
let resolvedURL = URL(string: nextLine, relativeTo: url)?.absoluteString
|
||||
?? baseURLString + "/" + nextLine
|
||||
|
||||
qualityURL = resolvedURL
|
||||
print("M3U8 Parser: Resolved relative URL to: \(qualityURL)")
|
||||
}
|
||||
|
||||
if !qualities.contains(where: { $0.0 == qualityName }) {
|
||||
qualities.append((qualityName, qualityURL))
|
||||
qualitiesFound += 1
|
||||
print("M3U8 Parser: Added quality option: \(qualityName) - \(qualityURL)")
|
||||
} else {
|
||||
print("M3U8 Parser: Skipped duplicate quality: \(qualityName)")
|
||||
}
|
||||
} else {
|
||||
print("M3U8 Parser: Failed to extract height from resolution: \(resolutionPart)")
|
||||
}
|
||||
} else {
|
||||
print("M3U8 Parser: Failed to extract resolution from line: \(line)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print("M3U8 Parser: Found \(qualitiesFound) distinct quality options (plus Auto)")
|
||||
print("M3U8 Parser: Total quality options: \(qualities.count)")
|
||||
completion(qualities)
|
||||
}.resume()
|
||||
}
|
||||
|
||||
/// Selects the appropriate quality based on user preference
|
||||
/// - Parameters:
|
||||
/// - qualities: Available quality options (name, URL)
|
||||
/// - preferredQuality: User's preferred quality
|
||||
/// - Returns: The selected quality (name, URL)
|
||||
private func selectQualityBasedOnPreference(qualities: [(String, String)], preferredQuality: String) -> (String, String) {
|
||||
// If only one quality is available, return it
|
||||
if qualities.count <= 1 {
|
||||
print("Quality Selection: Only one quality option available, returning it directly")
|
||||
return qualities[0]
|
||||
}
|
||||
|
||||
// Extract "Auto" quality and the remaining qualities
|
||||
let autoQuality = qualities.first { $0.0.contains("Auto") }
|
||||
let nonAutoQualities = qualities.filter { !$0.0.contains("Auto") }
|
||||
|
||||
print("Quality Selection: Found \(nonAutoQualities.count) non-Auto quality options")
|
||||
print("Quality Selection: Auto quality option: \(autoQuality?.0 ?? "None")")
|
||||
|
||||
// Sort non-auto qualities by resolution (highest first)
|
||||
let sortedQualities = nonAutoQualities.sorted { first, second in
|
||||
let firstHeight = Int(first.0.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0
|
||||
let secondHeight = Int(second.0.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0
|
||||
return firstHeight > secondHeight
|
||||
}
|
||||
|
||||
print("Quality Selection: Sorted qualities (highest to lowest):")
|
||||
for (index, quality) in sortedQualities.enumerated() {
|
||||
print(" \(index + 1). \(quality.0) - \(quality.1)")
|
||||
}
|
||||
|
||||
print("Quality Selection: User preference is '\(preferredQuality)'")
|
||||
|
||||
// Select quality based on preference
|
||||
switch preferredQuality {
|
||||
case "Best":
|
||||
// Return the highest quality (first in sorted list)
|
||||
let selected = sortedQualities.first ?? qualities[0]
|
||||
print("Quality Selection: Selected 'Best' quality: \(selected.0)")
|
||||
return selected
|
||||
|
||||
case "High":
|
||||
// Look for 720p quality
|
||||
let highQuality = sortedQualities.first {
|
||||
$0.0.contains("720p") || $0.0.contains("HD")
|
||||
}
|
||||
|
||||
if let high = highQuality {
|
||||
print("Quality Selection: Found specific 'High' (720p/HD) quality: \(high.0)")
|
||||
return high
|
||||
} else if let first = sortedQualities.first {
|
||||
print("Quality Selection: No specific 'High' quality found, using highest available: \(first.0)")
|
||||
return first
|
||||
} else {
|
||||
print("Quality Selection: No non-Auto qualities found, falling back to default: \(qualities[0].0)")
|
||||
return qualities[0]
|
||||
}
|
||||
|
||||
case "Medium":
|
||||
// Look for 480p quality
|
||||
let mediumQuality = sortedQualities.first {
|
||||
$0.0.contains("480p") || $0.0.contains("SD")
|
||||
}
|
||||
|
||||
if let medium = mediumQuality {
|
||||
print("Quality Selection: Found specific 'Medium' (480p/SD) quality: \(medium.0)")
|
||||
return medium
|
||||
} else if !sortedQualities.isEmpty {
|
||||
// Return middle quality from sorted list if no exact match
|
||||
let middleIndex = sortedQualities.count / 2
|
||||
print("Quality Selection: No specific 'Medium' quality found, using middle quality: \(sortedQualities[middleIndex].0)")
|
||||
return sortedQualities[middleIndex]
|
||||
} else {
|
||||
print("Quality Selection: No non-Auto qualities found, falling back to default: \(autoQuality?.0 ?? qualities[0].0)")
|
||||
return autoQuality ?? qualities[0]
|
||||
}
|
||||
|
||||
case "Low":
|
||||
// Return lowest quality (last in sorted list)
|
||||
if let lowest = sortedQualities.last {
|
||||
print("Quality Selection: Selected 'Low' quality: \(lowest.0)")
|
||||
return lowest
|
||||
} else {
|
||||
print("Quality Selection: No non-Auto qualities found, falling back to default: \(autoQuality?.0 ?? qualities[0].0)")
|
||||
return autoQuality ?? qualities[0]
|
||||
}
|
||||
|
||||
default:
|
||||
// Default to Auto if available, otherwise first quality
|
||||
if let auto = autoQuality {
|
||||
print("Quality Selection: Default case, using Auto quality: \(auto.0)")
|
||||
return auto
|
||||
} else {
|
||||
print("Quality Selection: No Auto quality found, using first available: \(qualities[0].0)")
|
||||
return qualities[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The original download method (adapted to be called internally)
|
||||
/// This method should match the existing download implementation in JSController-Downloads.swift
|
||||
private func downloadWithOriginalMethod(url: URL, headers: [String: String], title: String? = nil,
|
||||
imageURL: URL? = nil, isEpisode: Bool = false,
|
||||
showTitle: String? = nil, season: Int? = nil, episode: Int? = nil,
|
||||
subtitleURL: URL? = nil, showPosterURL: URL? = nil,
|
||||
completionHandler: ((Bool, String) -> Void)? = nil) {
|
||||
// Call the existing download method
|
||||
self.startDownload(
|
||||
url: url,
|
||||
headers: headers,
|
||||
title: title,
|
||||
imageURL: imageURL,
|
||||
isEpisode: isEpisode,
|
||||
showTitle: showTitle,
|
||||
season: season,
|
||||
episode: episode,
|
||||
subtitleURL: subtitleURL,
|
||||
showPosterURL: showPosterURL,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
//
|
||||
// JSController+MP4Download.swift
|
||||
// Sora
|
||||
//
|
||||
// Created by doomsboygaming on 5/22/25
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import AVFoundation
|
||||
|
||||
// Extension for handling MP4 direct video downloads using AVAssetDownloadTask
|
||||
extension JSController {
|
||||
|
||||
/// Initiates a download for a given MP4 URL using the existing AVAssetDownloadURLSession
|
||||
/// - Parameters:
|
||||
/// - url: The MP4 URL to download
|
||||
/// - headers: HTTP headers to use for the request
|
||||
/// - title: Title for the download (optional)
|
||||
/// - imageURL: Image URL for the content (optional)
|
||||
/// - isEpisode: Whether this is an episode (defaults to false)
|
||||
/// - showTitle: Title of the show this episode belongs to (optional)
|
||||
/// - season: Season number (optional)
|
||||
/// - episode: Episode number (optional)
|
||||
/// - subtitleURL: Optional subtitle URL to download after video (optional)
|
||||
/// - completionHandler: Called when the download is initiated or fails
|
||||
func downloadMP4(url: URL, headers: [String: String], title: String? = nil,
|
||||
imageURL: URL? = nil, isEpisode: Bool = false,
|
||||
showTitle: String? = nil, season: Int? = nil, episode: Int? = nil,
|
||||
subtitleURL: URL? = nil, showPosterURL: URL? = nil,
|
||||
completionHandler: ((Bool, String) -> Void)? = nil) {
|
||||
|
||||
// Validate URL
|
||||
guard url.scheme == "http" || url.scheme == "https" else {
|
||||
completionHandler?(false, "Invalid URL scheme")
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure download session is available
|
||||
guard let downloadSession = downloadURLSession else {
|
||||
completionHandler?(false, "Download session not available")
|
||||
return
|
||||
}
|
||||
|
||||
// Create metadata for the download
|
||||
var metadata: AssetMetadata? = nil
|
||||
if let title = title {
|
||||
metadata = AssetMetadata(
|
||||
title: title,
|
||||
posterURL: imageURL,
|
||||
showTitle: showTitle,
|
||||
season: season,
|
||||
episode: episode,
|
||||
showPosterURL: showPosterURL ?? imageURL
|
||||
)
|
||||
}
|
||||
|
||||
// Determine download type based on isEpisode
|
||||
let downloadType: DownloadType = isEpisode ? .episode : .movie
|
||||
|
||||
// Generate a unique download ID
|
||||
let downloadID = UUID()
|
||||
|
||||
// Create AVURLAsset with headers passed through AVURLAssetHTTPHeaderFieldsKey
|
||||
let asset = AVURLAsset(url: url, options: [
|
||||
"AVURLAssetHTTPHeaderFieldsKey": headers
|
||||
])
|
||||
|
||||
// Create AVAssetDownloadTask using existing session
|
||||
guard let downloadTask = downloadSession.makeAssetDownloadTask(
|
||||
asset: asset,
|
||||
assetTitle: title ?? url.lastPathComponent,
|
||||
assetArtworkData: nil,
|
||||
options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 2_000_000]
|
||||
) else {
|
||||
completionHandler?(false, "Failed to create download task")
|
||||
return
|
||||
}
|
||||
|
||||
// Create an active download object
|
||||
let activeDownload = JSActiveDownload(
|
||||
id: downloadID,
|
||||
originalURL: url,
|
||||
progress: 0.0,
|
||||
task: downloadTask,
|
||||
urlSessionTask: nil,
|
||||
queueStatus: .downloading,
|
||||
type: downloadType,
|
||||
metadata: metadata,
|
||||
title: title,
|
||||
imageURL: imageURL,
|
||||
subtitleURL: subtitleURL,
|
||||
asset: asset,
|
||||
headers: headers,
|
||||
module: nil
|
||||
)
|
||||
|
||||
// Add to active downloads and tracking
|
||||
activeDownloads.append(activeDownload)
|
||||
activeDownloadMap[downloadTask] = downloadID
|
||||
|
||||
// Set up progress observation for MP4 downloads
|
||||
setupMP4ProgressObservation(for: downloadTask, downloadID: downloadID)
|
||||
|
||||
// Start the download
|
||||
downloadTask.resume()
|
||||
|
||||
// Post notification for UI updates using NotificationCenter directly since postDownloadNotification is private
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("downloadStatusChanged"), object: nil)
|
||||
}
|
||||
|
||||
// Initial success callback
|
||||
completionHandler?(true, "Download started")
|
||||
}
|
||||
|
||||
// MARK: - MP4 Progress Observation
|
||||
|
||||
/// Sets up progress observation for MP4 downloads using AVAssetDownloadTask
|
||||
/// Since AVAssetDownloadTask doesn't provide progress for single MP4 files through delegate methods,
|
||||
/// we observe the task's progress property directly
|
||||
private func setupMP4ProgressObservation(for task: AVAssetDownloadTask, downloadID: UUID) {
|
||||
let observation = task.progress.observe(\.fractionCompleted, options: [.new]) { [weak self] progress, _ in
|
||||
DispatchQueue.main.async {
|
||||
guard let self = self else { return }
|
||||
|
||||
// Update download progress using existing infrastructure
|
||||
self.updateMP4DownloadProgress(task: task, progress: progress.fractionCompleted)
|
||||
|
||||
// Post notification for UI updates
|
||||
NotificationCenter.default.post(name: NSNotification.Name("downloadProgressChanged"), object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Store observation for cleanup using existing property from main JSController class
|
||||
if mp4ProgressObservations == nil {
|
||||
mp4ProgressObservations = [:]
|
||||
}
|
||||
mp4ProgressObservations?[downloadID] = observation
|
||||
}
|
||||
|
||||
/// Updates download progress for a specific MP4 task (avoiding name collision with existing method)
|
||||
private func updateMP4DownloadProgress(task: AVAssetDownloadTask, progress: Double) {
|
||||
guard let downloadID = activeDownloadMap[task],
|
||||
let downloadIndex = activeDownloads.firstIndex(where: { $0.id == downloadID }) else {
|
||||
return
|
||||
}
|
||||
|
||||
// Update progress using existing mechanism
|
||||
activeDownloads[downloadIndex].progress = progress
|
||||
}
|
||||
|
||||
/// Cleans up MP4 progress observation for a specific download
|
||||
func cleanupMP4ProgressObservation(for downloadID: UUID) {
|
||||
mp4ProgressObservations?[downloadID]?.invalidate()
|
||||
mp4ProgressObservations?[downloadID] = nil
|
||||
}
|
||||
}
|
||||
|
|
@ -38,15 +38,6 @@ extension JSController {
|
|||
showPosterURL: URL? = nil,
|
||||
completionHandler: ((Bool, String) -> Void)? = nil
|
||||
) {
|
||||
print("---- STREAM TYPE DOWNLOAD PROCESS STARTED ----")
|
||||
print("Original URL: \(url.absoluteString)")
|
||||
print("Stream Type: \(module.metadata.streamType)")
|
||||
print("Headers: \(headers)")
|
||||
print("Title: \(title ?? "None")")
|
||||
print("Is Episode: \(isEpisode), Show: \(showTitle ?? "None"), Season: \(season?.description ?? "None"), Episode: \(episode?.description ?? "None")")
|
||||
if let subtitle = subtitleURL {
|
||||
print("Subtitle URL: \(subtitle.absoluteString)")
|
||||
}
|
||||
let streamType = module.metadata.streamType.lowercased()
|
||||
|
||||
if streamType == "hls" || streamType == "m3u8" || url.absoluteString.contains(".m3u8") {
|
||||
|
|
|
|||
|
|
@ -21,18 +21,18 @@ extension JSController {
|
|||
guard let self = self else { return }
|
||||
|
||||
if let error = error {
|
||||
Logger.shared.log("Network error: \(error)",type: "Error")
|
||||
Logger.shared.log("Network error while searching: \(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("Could not decode HTML response", 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,7 +46,7 @@ extension JSController {
|
|||
completion(resultItems)
|
||||
}
|
||||
} else {
|
||||
Logger.shared.log("Failed to parse results",type: "Error")
|
||||
Logger.shared.log("Could not parse search results", type: "Error")
|
||||
DispatchQueue.main.async { completion([]) }
|
||||
}
|
||||
}.resume()
|
||||
|
|
@ -54,27 +54,27 @@ extension JSController {
|
|||
|
||||
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("Search function not found in module", 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("Search function returned invalid response", 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 {
|
||||
|
|
@ -83,7 +83,7 @@ extension JSController {
|
|||
guard let title = item["title"] as? String,
|
||||
let imageUrl = item["image"] as? String,
|
||||
let href = item["href"] as? String else {
|
||||
Logger.shared.log("Missing or invalid data in search result item: \(item)", type: "Error")
|
||||
Logger.shared.log("Invalid search result data format", type: "Error")
|
||||
return nil
|
||||
}
|
||||
return SearchItem(title: title, imageUrl: imageUrl, href: href)
|
||||
|
|
@ -94,19 +94,19 @@ extension JSController {
|
|||
}
|
||||
|
||||
} else {
|
||||
Logger.shared.log("Failed to parse JSON",type: "Error")
|
||||
Logger.shared.log("Could not parse JSON response", 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("Invalid search result format", type: "Error")
|
||||
DispatchQueue.main.async {
|
||||
completion([])
|
||||
}
|
||||
|
|
@ -114,7 +114,7 @@ extension JSController {
|
|||
}
|
||||
|
||||
let catchBlock: @convention(block) (JSValue) -> Void = { error in
|
||||
Logger.shared.log("Promise rejected: \(String(describing: error.toString()))",type: "Error")
|
||||
Logger.shared.log("Search operation failed: \(String(describing: error.toString()))", type: "Error")
|
||||
DispatchQueue.main.async {
|
||||
completion([])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,9 +62,7 @@ class JSController: NSObject, ObservableObject {
|
|||
}
|
||||
|
||||
func updateMaxConcurrentDownloads(_ newLimit: Int) {
|
||||
print("Updating max concurrent downloads from \(maxConcurrentDownloads) to \(newLimit)")
|
||||
if !downloadQueue.isEmpty && !isProcessingQueue {
|
||||
print("Processing download queue due to increased concurrent limit. Queue has \(downloadQueue.count) items.")
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
|
@ -75,7 +73,7 @@ class JSController: NSObject, ObservableObject {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
print("No queued downloads to process or queue is already being processed")
|
||||
Logger.shared.log("No queued downloads to process or queue is already being processed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
let onWatchNext: () -> Void
|
||||
let aniListID: Int
|
||||
var headers: [String:String]? = nil
|
||||
var tmdbID: Int? = nil
|
||||
var isMovie: Bool = false
|
||||
var seasonNumber: Int = 1
|
||||
|
||||
private var aniListUpdatedSuccessfully = false
|
||||
private var aniListUpdateImpossible: Bool = false
|
||||
|
|
@ -31,6 +34,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
private let aniListMaxRetries = 6
|
||||
private let totalEpisodes: Int
|
||||
|
||||
private var traktUpdateSent = false
|
||||
private var traktUpdatedSuccessfully = false
|
||||
|
||||
var player: AVPlayer!
|
||||
var timeObserverToken: Any?
|
||||
var inactivityTimer: Timer?
|
||||
|
|
@ -1291,7 +1297,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
dimButtonToRight = dimButton.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -16)
|
||||
dimButtonToSlider.isActive = true
|
||||
}
|
||||
|
||||
private func setupLockButton() {
|
||||
let cfg = UIImage.SymbolConfiguration(pointSize: 24, weight: .regular)
|
||||
lockButton = UIButton(type: .system)
|
||||
|
|
@ -1385,19 +1390,14 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
pipButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
pipButton.heightAnchor.constraint(equalToConstant: 44),
|
||||
airplayButton.centerYAnchor.constraint(equalTo: pipButton.centerYAnchor),
|
||||
airplayButton.trailingAnchor.constraint(equalTo: pipButton.leadingAnchor, constant: -8),
|
||||
airplayButton.trailingAnchor.constraint(equalTo: pipButton.leadingAnchor, constant: -6),
|
||||
airplayButton.widthAnchor.constraint(equalToConstant: 44),
|
||||
airplayButton.heightAnchor.constraint(equalToConstant: 44)
|
||||
])
|
||||
|
||||
pipButton.isHidden = !isPipButtonVisible
|
||||
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(startPipIfNeeded),
|
||||
name: UIApplication.willResignActiveNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(startPipIfNeeded), name: UIApplication.willResignActiveNotification, object: nil)
|
||||
}
|
||||
|
||||
func setupMenuButton() {
|
||||
|
|
@ -1644,12 +1644,14 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
|
||||
let remainingPercentage = (self.duration - self.currentTimeVal) / self.duration
|
||||
|
||||
if remainingPercentage < 0.1 &&
|
||||
self.aniListID != 0 &&
|
||||
!self.aniListUpdatedSuccessfully &&
|
||||
!self.aniListUpdateImpossible
|
||||
{
|
||||
self.tryAniListUpdate()
|
||||
if remainingPercentage < 0.1 {
|
||||
if self.aniListID != 0 && !self.aniListUpdatedSuccessfully && !self.aniListUpdateImpossible {
|
||||
self.tryAniListUpdate()
|
||||
}
|
||||
|
||||
if let tmdbId = self.tmdbID, tmdbId > 0, !self.traktUpdateSent {
|
||||
self.sendTraktUpdate(tmdbId: tmdbId)
|
||||
}
|
||||
}
|
||||
|
||||
self.sliderHostingController?.rootView = MusicProgressSlider(
|
||||
|
|
@ -1796,6 +1798,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
@objc func seekBackward() {
|
||||
let skipValue = UserDefaults.standard.double(forKey: "skipIncrement")
|
||||
let finalSkip = skipValue > 0 ? skipValue : 10
|
||||
|
||||
currentTimeVal = max(currentTimeVal - finalSkip, 0)
|
||||
player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in
|
||||
guard self != nil else { return }
|
||||
|
|
@ -1805,6 +1808,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
|
||||
@objc func seekForward() {
|
||||
let skipValue = UserDefaults.standard.double(forKey: "skipIncrement")
|
||||
|
||||
let finalSkip = skipValue > 0 ? skipValue : 10
|
||||
currentTimeVal = min(currentTimeVal + finalSkip, duration)
|
||||
player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in
|
||||
|
|
@ -1865,8 +1869,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
guard isPipAutoEnabled,
|
||||
let pip = pipController,
|
||||
!pip.isPictureInPictureActive else {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
pip.startPictureInPicture()
|
||||
}
|
||||
|
||||
|
|
@ -2061,6 +2065,45 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
}
|
||||
}
|
||||
|
||||
private func sendTraktUpdate(tmdbId: Int) {
|
||||
guard !traktUpdateSent else { return }
|
||||
traktUpdateSent = true
|
||||
|
||||
let traktMutation = TraktMutation()
|
||||
|
||||
if self.isMovie {
|
||||
traktMutation.markAsWatched(type: "movie", tmdbID: tmdbId) { [weak self] result in
|
||||
switch result {
|
||||
case .success:
|
||||
self?.traktUpdatedSuccessfully = true
|
||||
Logger.shared.log("Successfully updated Trakt progress for movie (TMDB: \(tmdbId))", type: "General")
|
||||
case .failure(let error):
|
||||
Logger.shared.log("Failed to update Trakt progress for movie: \(error.localizedDescription)", type: "Error")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
guard self.episodeNumber > 0 && self.seasonNumber > 0 else {
|
||||
Logger.shared.log("Invalid episode (\(self.episodeNumber)) or season (\(self.seasonNumber)) number for Trakt update", type: "Error")
|
||||
return
|
||||
}
|
||||
|
||||
traktMutation.markAsWatched(
|
||||
type: "episode",
|
||||
tmdbID: tmdbId,
|
||||
episodeNumber: self.episodeNumber,
|
||||
seasonNumber: self.seasonNumber
|
||||
) { [weak self] result in
|
||||
switch result {
|
||||
case .success:
|
||||
self?.traktUpdatedSuccessfully = true
|
||||
Logger.shared.log("Successfully updated Trakt progress for Episode \(self?.episodeNumber ?? 0) (TMDB: \(tmdbId))", type: "General")
|
||||
case .failure(let error):
|
||||
Logger.shared.log("Failed to update Trakt progress for episode: \(error.localizedDescription)", type: "Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func animateButtonRotation(_ button: UIView, clockwise: Bool = true) {
|
||||
if button.layer.animation(forKey: "rotate360") != nil {
|
||||
return
|
||||
|
|
@ -2114,13 +2157,13 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
private func processM3U8Data(data: Data?, url: URL, completion: @escaping () -> Void) {
|
||||
guard let data = data,
|
||||
let content = String(data: data, encoding: .utf8) else {
|
||||
Logger.shared.log("Failed to load m3u8 file")
|
||||
DispatchQueue.main.async {
|
||||
self.qualities = []
|
||||
completion()
|
||||
}
|
||||
return
|
||||
}
|
||||
Logger.shared.log("Failed to load m3u8 file")
|
||||
DispatchQueue.main.async {
|
||||
self.qualities = []
|
||||
completion()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let lines = content.components(separatedBy: .newlines)
|
||||
var qualities: [(String, String)] = []
|
||||
|
|
@ -2185,7 +2228,13 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
|
||||
private func switchToQuality(urlString: String) {
|
||||
guard let url = URL(string: urlString),
|
||||
currentQualityURL?.absoluteString != urlString else { return }
|
||||
currentQualityURL?.absoluteString != urlString else {
|
||||
Logger.shared.log("Quality Selection: Switch cancelled - same quality already selected", type: "General")
|
||||
return
|
||||
}
|
||||
|
||||
let qualityName = qualities.first(where: { $0.1 == urlString })?.0 ?? "Unknown"
|
||||
Logger.shared.log("Quality Selection: Switching to quality: \(qualityName) (\(urlString))", type: "General")
|
||||
|
||||
let currentTime = player.currentTime()
|
||||
let wasPlaying = player.rate > 0
|
||||
|
|
@ -2244,7 +2293,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
qualityButton.menu = qualitySelectionMenu()
|
||||
|
||||
if let selectedQuality = qualities.first(where: { $0.1 == urlString })?.0 {
|
||||
Logger.shared.log("Quality Selection: Successfully switched to: \(selectedQuality)", type: "General")
|
||||
DropManager.shared.showDrop(title: "Quality: \(selectedQuality)", subtitle: "", duration: 0.5, icon: UIImage(systemName: "eye"))
|
||||
} else {
|
||||
Logger.shared.log("Quality Selection: Switch completed but quality name not found in list", type: "General")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2294,11 +2346,34 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
baseM3U8URL = url
|
||||
currentQualityURL = url
|
||||
|
||||
let networkType = NetworkMonitor.getCurrentNetworkType()
|
||||
let networkTypeString = networkType == .wifi ? "WiFi" : networkType == .cellular ? "Cellular" : "Unknown"
|
||||
Logger.shared.log("Quality Selection: Detected network type: \(networkTypeString)", type: "General")
|
||||
|
||||
parseM3U8(url: url) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if let last = UserDefaults.standard.string(forKey: "lastSelectedQuality"),
|
||||
self.qualities.contains(where: { $0.1 == last }) {
|
||||
self.switchToQuality(urlString: last)
|
||||
|
||||
Logger.shared.log("Quality Selection: Found \(self.qualities.count) available qualities", type: "General")
|
||||
for (index, quality) in self.qualities.enumerated() {
|
||||
Logger.shared.log("Quality Selection: Available [\(index + 1)]: \(quality.0) - \(quality.1)", type: "General")
|
||||
}
|
||||
|
||||
let preferredQuality = UserDefaults.getVideoQualityPreference()
|
||||
Logger.shared.log("Quality Selection: User preference for \(networkTypeString): \(preferredQuality.rawValue)", type: "General")
|
||||
|
||||
if let selectedQuality = VideoQualityPreference.findClosestQuality(preferred: preferredQuality, availableQualities: self.qualities) {
|
||||
Logger.shared.log("Quality Selection: Selected quality: \(selectedQuality.0) (URL: \(selectedQuality.1))", type: "General")
|
||||
self.switchToQuality(urlString: selectedQuality.1)
|
||||
} else {
|
||||
Logger.shared.log("Quality Selection: No matching quality found, using default", type: "General")
|
||||
if let last = UserDefaults.standard.string(forKey: "lastSelectedQuality"),
|
||||
self.qualities.contains(where: { $0.1 == last }) {
|
||||
Logger.shared.log("Quality Selection: Falling back to last selected quality", type: "General")
|
||||
self.switchToQuality(urlString: last)
|
||||
} else if let firstQuality = self.qualities.first {
|
||||
Logger.shared.log("Quality Selection: Falling back to first available quality: \(firstQuality.0)", type: "General")
|
||||
self.switchToQuality(urlString: firstQuality.1)
|
||||
}
|
||||
}
|
||||
|
||||
self.qualityButton.isHidden = false
|
||||
|
|
@ -2312,6 +2387,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
isHLSStream = false
|
||||
qualityButton.isHidden = true
|
||||
updateMenuButtonConstraints()
|
||||
Logger.shared.log("Quality Selection: Non-HLS stream detected, quality selection unavailable", type: "General")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2686,7 +2762,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
height: 10,
|
||||
onEditingChanged: { _ in }
|
||||
)
|
||||
.shadow(color: Color.black.opacity(0.6), radius: 4, x: 0, y: 2)
|
||||
.shadow(color: Color.black.opacity(0.6), radius: 4, x: 0, y: 2)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2701,6 +2777,57 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
default: return .white
|
||||
}
|
||||
}
|
||||
|
||||
override var canBecomeFirstResponder: Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
override var keyCommands: [UIKeyCommand]? {
|
||||
return [
|
||||
UIKeyCommand(input: " ", modifierFlags: [], action: #selector(handleSpaceKey), discoverabilityTitle: "Play/Pause"),
|
||||
UIKeyCommand(input: UIKeyCommand.inputLeftArrow, modifierFlags: [], action: #selector(handleLeftArrow), discoverabilityTitle: "Seek Backward 10s"),
|
||||
UIKeyCommand(input: UIKeyCommand.inputRightArrow, modifierFlags: [], action: #selector(handleRightArrow), discoverabilityTitle: "Seek Forward 10s"),
|
||||
UIKeyCommand(input: UIKeyCommand.inputUpArrow, modifierFlags: [], action: #selector(handleUpArrow), discoverabilityTitle: "Seek Forward 60s"),
|
||||
UIKeyCommand(input: UIKeyCommand.inputDownArrow, modifierFlags: [], action: #selector(handleDownArrow), discoverabilityTitle: "Seek Backward 60s"),
|
||||
UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(handleEscape), discoverabilityTitle: "Dismiss Player")
|
||||
]
|
||||
}
|
||||
|
||||
@objc private func handleSpaceKey() {
|
||||
togglePlayPause()
|
||||
}
|
||||
|
||||
@objc private func handleLeftArrow() {
|
||||
let skipValue = 10.0
|
||||
currentTimeVal = max(currentTimeVal - skipValue, 0)
|
||||
player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600))
|
||||
animateButtonRotation(backwardButton, clockwise: false)
|
||||
}
|
||||
|
||||
@objc private func handleRightArrow() {
|
||||
let skipValue = 10.0
|
||||
currentTimeVal = min(currentTimeVal + skipValue, duration)
|
||||
player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600))
|
||||
animateButtonRotation(forwardButton)
|
||||
}
|
||||
|
||||
@objc private func handleUpArrow() {
|
||||
let skipValue = 60.0
|
||||
currentTimeVal = min(currentTimeVal + skipValue, duration)
|
||||
player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600))
|
||||
animateButtonRotation(forwardButton)
|
||||
}
|
||||
|
||||
@objc private func handleDownArrow() {
|
||||
let skipValue = 60.0
|
||||
currentTimeVal = max(currentTimeVal - skipValue, 0)
|
||||
player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600))
|
||||
animateButtonRotation(backwardButton, clockwise: false)
|
||||
}
|
||||
|
||||
@objc private func handleEscape() {
|
||||
dismiss(animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
class GradientOverlayButton: UIButton {
|
||||
|
|
|
|||
|
|
@ -20,20 +20,79 @@ class VideoPlayerViewController: UIViewController {
|
|||
var aniListID: Int = 0
|
||||
var headers: [String:String]? = nil
|
||||
var totalEpisodes: Int = 0
|
||||
|
||||
var tmdbID: Int? = nil
|
||||
var isMovie: Bool = false
|
||||
var seasonNumber: Int = 1
|
||||
var episodeNumber: Int = 0
|
||||
var episodeImageUrl: String = ""
|
||||
var mediaTitle: String = ""
|
||||
var subtitlesLoader: VTTSubtitlesLoader?
|
||||
var subtitleLabel: UILabel?
|
||||
|
||||
private var aniListUpdateSent = false
|
||||
private var aniListUpdatedSuccessfully = false
|
||||
private var traktUpdateSent = false
|
||||
private var traktUpdatedSuccessfully = false
|
||||
|
||||
init(module: ScrapingModule) {
|
||||
self.module = module
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
if UserDefaults.standard.object(forKey: "subtitlesEnabled") == nil {
|
||||
UserDefaults.standard.set(true, forKey: "subtitlesEnabled")
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func setupSubtitles() {
|
||||
guard !subtitles.isEmpty, UserDefaults.standard.bool(forKey: "subtitlesEnabled"), let subtitleURL = URL(string: subtitles) else {
|
||||
return
|
||||
}
|
||||
|
||||
subtitlesLoader = VTTSubtitlesLoader()
|
||||
setupSubtitleLabel()
|
||||
|
||||
subtitlesLoader?.load(from: subtitles)
|
||||
|
||||
let interval = CMTime(seconds: 0.1, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
||||
player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
||||
self?.updateSubtitles(at: time.seconds)
|
||||
}
|
||||
}
|
||||
|
||||
private func setupSubtitleLabel() {
|
||||
let label = UILabel()
|
||||
label.numberOfLines = 0
|
||||
label.textAlignment = .center
|
||||
label.textColor = .white
|
||||
label.font = .systemFont(ofSize: 16, weight: .medium)
|
||||
label.layer.shadowColor = UIColor.black.cgColor
|
||||
label.layer.shadowOffset = CGSize(width: 1, height: 1)
|
||||
label.layer.shadowOpacity = 0.8
|
||||
label.layer.shadowRadius = 2
|
||||
|
||||
guard let playerView = playerViewController?.view else { return }
|
||||
playerView.addSubview(label)
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
label.leadingAnchor.constraint(equalTo: playerView.leadingAnchor, constant: 16),
|
||||
label.trailingAnchor.constraint(equalTo: playerView.trailingAnchor, constant: -16),
|
||||
label.bottomAnchor.constraint(equalTo: playerView.bottomAnchor, constant: -32)
|
||||
])
|
||||
|
||||
self.subtitleLabel = label
|
||||
}
|
||||
|
||||
private func updateSubtitles(at time: Double) {
|
||||
let currentSubtitle = subtitlesLoader?.cues.first { cue in
|
||||
time >= cue.startTime && time <= cue.endTime
|
||||
}
|
||||
subtitleLabel?.text = currentSubtitle?.text ?? ""
|
||||
}
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
|
|
@ -66,6 +125,10 @@ class VideoPlayerViewController: UIViewController {
|
|||
playerViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
view.addSubview(playerViewController.view)
|
||||
playerViewController.didMove(toParent: self)
|
||||
|
||||
if !subtitles.isEmpty && UserDefaults.standard.bool(forKey: "subtitlesEnabled") {
|
||||
setupSubtitles()
|
||||
}
|
||||
}
|
||||
|
||||
addPeriodicTimeObserver(fullURL: fullUrl)
|
||||
|
|
@ -113,8 +176,8 @@ class VideoPlayerViewController: UIViewController {
|
|||
guard let self = self,
|
||||
let currentItem = player.currentItem,
|
||||
currentItem.duration.seconds.isFinite else {
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let currentTime = time.seconds
|
||||
let duration = currentItem.duration.seconds
|
||||
|
|
@ -144,15 +207,69 @@ class VideoPlayerViewController: UIViewController {
|
|||
|
||||
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
|
||||
switch result {
|
||||
case .success:
|
||||
Logger.shared.log("Successfully updated AniList progress for episode \(self.episodeNumber)", type: "General")
|
||||
case .failure(let error):
|
||||
Logger.shared.log("Failed to update AniList progress: \(error.localizedDescription)", type: "Error")
|
||||
}
|
||||
if remainingPercentage < 0.1 {
|
||||
if self.aniListID != 0 && !self.aniListUpdateSent {
|
||||
self.sendAniListUpdate()
|
||||
}
|
||||
|
||||
if let tmdbId = self.tmdbID, tmdbId > 0, !self.traktUpdateSent {
|
||||
self.sendTraktUpdate(tmdbId: tmdbId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sendAniListUpdate() {
|
||||
guard !aniListUpdateSent else { return }
|
||||
|
||||
aniListUpdateSent = true
|
||||
let aniListMutation = AniListMutation()
|
||||
|
||||
aniListMutation.updateAnimeProgress(animeId: self.aniListID, episodeNumber: self.episodeNumber) { [weak self] result in
|
||||
switch result {
|
||||
case .success:
|
||||
self?.aniListUpdatedSuccessfully = true
|
||||
Logger.shared.log("Successfully updated AniList progress for Episode \(self?.episodeNumber ?? 0)", type: "General")
|
||||
case .failure(let error):
|
||||
Logger.shared.log("Failed to update AniList progress: \(error.localizedDescription)", type: "Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sendTraktUpdate(tmdbId: Int) {
|
||||
guard !traktUpdateSent else { return }
|
||||
traktUpdateSent = true
|
||||
|
||||
let traktMutation = TraktMutation()
|
||||
|
||||
if self.isMovie {
|
||||
traktMutation.markAsWatched(type: "movie", tmdbID: tmdbId) { [weak self] result in
|
||||
switch result {
|
||||
case .success:
|
||||
self?.traktUpdatedSuccessfully = true
|
||||
Logger.shared.log("Successfully updated Trakt progress for movie (TMDB: \(tmdbId))", type: "General")
|
||||
case .failure(let error):
|
||||
Logger.shared.log("Failed to update Trakt progress for movie: \(error.localizedDescription)", type: "Error")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
guard self.episodeNumber > 0 && self.seasonNumber > 0 else {
|
||||
Logger.shared.log("Invalid episode (\(self.episodeNumber)) or season (\(self.seasonNumber)) number for Trakt update", type: "Error")
|
||||
return
|
||||
}
|
||||
|
||||
traktMutation.markAsWatched(
|
||||
type: "episode",
|
||||
tmdbID: tmdbId,
|
||||
episodeNumber: self.episodeNumber,
|
||||
seasonNumber: self.seasonNumber
|
||||
) { [weak self] result in
|
||||
switch result {
|
||||
case .success:
|
||||
self?.traktUpdatedSuccessfully = true
|
||||
Logger.shared.log("Successfully updated Trakt progress for Episode \(self?.episodeNumber ?? 0) (TMDB: \(tmdbId))", type: "General")
|
||||
case .failure(let error):
|
||||
Logger.shared.log("Failed to update Trakt progress for episode: \(error.localizedDescription)", type: "Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -179,5 +296,8 @@ class VideoPlayerViewController: UIViewController {
|
|||
if let timeObserverToken = timeObserverToken {
|
||||
player?.removeTimeObserver(timeObserverToken)
|
||||
}
|
||||
subtitleLabel?.removeFromSuperview()
|
||||
subtitleLabel = nil
|
||||
subtitlesLoader = nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,20 +22,20 @@ struct ModuleAdditionSettingsView: View {
|
|||
ZStack {
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
colorScheme == .light ? Color.black : Color.white,
|
||||
Color.accentColor.opacity(0.08)
|
||||
colorScheme == .dark ? Color.black : Color.white,
|
||||
Color.accentColor.opacity(0.05)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
.ignoresSafeArea()
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Spacer()
|
||||
Capsule()
|
||||
.frame(width: 40, height: 5)
|
||||
.foregroundColor(Color(.systemGray4))
|
||||
.foregroundColor(Color(.systemGray3))
|
||||
.padding(.top, 10)
|
||||
Spacer()
|
||||
}
|
||||
|
|
@ -57,17 +57,22 @@ struct ModuleAdditionSettingsView: View {
|
|||
}
|
||||
.frame(width: 90, height: 90)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
||||
.shadow(color: Color.accentColor.opacity(0.18), radius: 10, x: 0, y: 6)
|
||||
.shadow(
|
||||
color: colorScheme == .dark
|
||||
? Color.black.opacity(0.3)
|
||||
: Color.accentColor.opacity(0.15),
|
||||
radius: 10, x: 0, y: 6
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 22)
|
||||
.stroke(Color.accentColor, lineWidth: 2)
|
||||
.stroke(Color.accentColor.opacity(0.8), lineWidth: 2)
|
||||
)
|
||||
.padding(.top, 10)
|
||||
|
||||
VStack(spacing: 6) {
|
||||
Text(metadata.sourceName)
|
||||
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||
.foregroundColor(.primary)
|
||||
.foregroundColor(colorScheme == .dark ? .white : .black)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.top, 6)
|
||||
|
||||
|
|
@ -84,14 +89,19 @@ struct ModuleAdditionSettingsView: View {
|
|||
}
|
||||
.frame(width: 32, height: 32)
|
||||
.clipShape(Circle())
|
||||
.shadow(radius: 2)
|
||||
.shadow(
|
||||
color: colorScheme == .dark
|
||||
? Color.black.opacity(0.4)
|
||||
: Color.gray.opacity(0.3),
|
||||
radius: 2
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(metadata.author.name)
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
.foregroundColor(colorScheme == .dark ? .white : .black)
|
||||
Text("Author")
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(colorScheme == .dark ? Color.white.opacity(0.7) : Color.black.opacity(0.6))
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
|
@ -99,7 +109,11 @@ struct ModuleAdditionSettingsView: View {
|
|||
.padding(.vertical, 8)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color.accentColor.opacity(colorScheme == .dark ? 0.13 : 0.08))
|
||||
.fill(
|
||||
colorScheme == .dark
|
||||
? Color.accentColor.opacity(0.15)
|
||||
: Color.accentColor.opacity(0.08)
|
||||
)
|
||||
)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
|
|
@ -125,7 +139,7 @@ struct ModuleAdditionSettingsView: View {
|
|||
}
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 22)
|
||||
.fill(Color(.systemGray6).opacity(colorScheme == .dark ? 0.18 : 0.8))
|
||||
.fill(colorScheme == .dark ? Color.white.opacity(0.1) : Color.black.opacity(0.05))
|
||||
)
|
||||
.padding(.top, 18)
|
||||
.padding(.horizontal, 2)
|
||||
|
|
@ -142,7 +156,7 @@ struct ModuleAdditionSettingsView: View {
|
|||
.padding(16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18)
|
||||
.fill(Color(.systemGray6).opacity(colorScheme == .dark ? 0.13 : 0.85))
|
||||
.fill(colorScheme == .dark ? Color.white.opacity(0.08) : Color.black.opacity(0.04))
|
||||
)
|
||||
.padding(.top, 18)
|
||||
}
|
||||
|
|
@ -152,8 +166,10 @@ struct ModuleAdditionSettingsView: View {
|
|||
VStack(spacing: 20) {
|
||||
ProgressView()
|
||||
.scaleEffect(1.5)
|
||||
.tint(.accentColor)
|
||||
Text("Loading module information...")
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(colorScheme == .dark ? Color.white.opacity(0.7) : Color.black.opacity(0.6))
|
||||
.font(.body)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding(.top, 100)
|
||||
|
|
@ -165,6 +181,7 @@ struct ModuleAdditionSettingsView: View {
|
|||
Text(errorMessage)
|
||||
.foregroundColor(.red)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(.body)
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.padding(.top, 100)
|
||||
|
|
@ -180,21 +197,26 @@ struct ModuleAdditionSettingsView: View {
|
|||
Text("Add Module")
|
||||
}
|
||||
.font(.headline)
|
||||
.foregroundColor(colorScheme == .light ? .black : .white)
|
||||
.foregroundColor(Color.accentColor)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.background(
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [
|
||||
Color.accentColor.opacity(0.95),
|
||||
Color.accentColor.opacity(0.7)
|
||||
colorScheme == .dark ? Color.white : Color.black,
|
||||
colorScheme == .dark ? Color.white.opacity(0.9) : Color.black.opacity(0.9)
|
||||
]),
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18))
|
||||
)
|
||||
.shadow(color: Color.accentColor.opacity(0.18), radius: 8, x: 0, y: 4)
|
||||
.shadow(
|
||||
color: colorScheme == .dark
|
||||
? Color.black.opacity(0.3)
|
||||
: Color.accentColor.opacity(0.25),
|
||||
radius: 8, x: 0, y: 4
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
.disabled(isLoading || moduleMetadata == nil)
|
||||
|
|
@ -203,7 +225,7 @@ struct ModuleAdditionSettingsView: View {
|
|||
Button(action: { presentationMode.wrappedValue.dismiss() }) {
|
||||
Text("Cancel")
|
||||
.font(.body)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(colorScheme == .dark ? Color.white.opacity(0.7) : Color.black.opacity(0.6))
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
|
|
@ -271,18 +293,19 @@ struct FancyInfoTile: View {
|
|||
let icon: String
|
||||
let label: String
|
||||
let value: String
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundColor(.accentColor)
|
||||
.foregroundColor(colorScheme == .dark ? .white : .black)
|
||||
Text(label)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(colorScheme == .dark ? Color.white.opacity(0.6) : Color.black.opacity(0.5))
|
||||
Text(value)
|
||||
.font(.system(size: 15, weight: .semibold, design: .rounded))
|
||||
.foregroundColor(.primary)
|
||||
.foregroundColor(colorScheme == .dark ? .white : .black)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
|
|
@ -294,16 +317,17 @@ struct FancyInfoTile: View {
|
|||
struct FancyUrlRow: View {
|
||||
let title: String
|
||||
let value: String
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text(title)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(colorScheme == .dark ? Color.white.opacity(0.7) : Color.black.opacity(0.6))
|
||||
Spacer()
|
||||
Text(value)
|
||||
.font(.footnote.monospaced())
|
||||
.foregroundColor(.accentColor)
|
||||
.foregroundColor(colorScheme == .dark ? .white : .black)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.onLongPressGesture {
|
||||
|
|
@ -311,7 +335,7 @@ struct FancyUrlRow: View {
|
|||
DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill"))
|
||||
}
|
||||
Image(systemName: "doc.on.clipboard")
|
||||
.foregroundColor(.accentColor)
|
||||
.foregroundColor(colorScheme == .dark ? .white : .black)
|
||||
.font(.system(size: 14))
|
||||
.onTapGesture {
|
||||
UIPasteboard.general.string = value
|
||||
|
|
|
|||
|
|
@ -8,56 +8,48 @@
|
|||
import SwiftUI
|
||||
|
||||
struct Shimmer: ViewModifier {
|
||||
@State private var phase: CGFloat = -1
|
||||
@State private var phase: CGFloat = 0
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.modifier(AnimatedMask(phase: phase)
|
||||
.animation(
|
||||
Animation.linear(duration: 1.2)
|
||||
.repeatForever(autoreverses: false)
|
||||
)
|
||||
.overlay(
|
||||
shimmerOverlay
|
||||
.allowsHitTesting(false)
|
||||
)
|
||||
.onAppear {
|
||||
phase = 1.5
|
||||
startAnimation()
|
||||
}
|
||||
}
|
||||
|
||||
struct AnimatedMask: AnimatableModifier {
|
||||
var phase: CGFloat = 0
|
||||
|
||||
var animatableData: CGFloat {
|
||||
get { phase }
|
||||
set { phase = newValue }
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay(
|
||||
GeometryReader { geo in
|
||||
let width = geo.size.width
|
||||
let shimmerStart = phase - 0.25
|
||||
let shimmerEnd = phase + 0.25
|
||||
Rectangle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.white.opacity(0.05), location: shimmerStart - 0.15),
|
||||
.init(color: Color.white.opacity(0.25), location: shimmerStart),
|
||||
.init(color: Color.white.opacity(0.85), location: phase),
|
||||
.init(color: Color.white.opacity(0.25), location: shimmerEnd),
|
||||
.init(color: Color.white.opacity(0.05), location: shimmerEnd + 0.15)
|
||||
]),
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
)
|
||||
.blur(radius: 8)
|
||||
.rotationEffect(.degrees(20))
|
||||
.offset(x: -width * 0.7 + width * 2 * phase)
|
||||
}
|
||||
)
|
||||
.mask(content)
|
||||
}
|
||||
private var shimmerOverlay: some View {
|
||||
Rectangle()
|
||||
.fill(shimmerGradient)
|
||||
.scaleEffect(x: 3, y: 1)
|
||||
.rotationEffect(.degrees(20))
|
||||
.offset(x: -200 + (400 * phase))
|
||||
.animation(
|
||||
.linear(duration: 1.2)
|
||||
.repeatForever(autoreverses: false),
|
||||
value: phase
|
||||
)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
private var shimmerGradient: LinearGradient {
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .clear, location: 0),
|
||||
.init(color: .white.opacity(0.1), location: 0.3),
|
||||
.init(color: .white.opacity(0.6), location: 0.5),
|
||||
.init(color: .white.opacity(0.1), location: 0.7),
|
||||
.init(color: .clear, location: 1)
|
||||
],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
}
|
||||
|
||||
private func startAnimation() {
|
||||
phase = 1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ struct TabBar: View {
|
|||
.stroke(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.accentColor.opacity(gradientOpacity), location: 0),
|
||||
.init(color: Color.accentColor.opacity(0.25), location: 0),
|
||||
.init(color: Color.accentColor.opacity(0), location: 1)
|
||||
]),
|
||||
startPoint: .top,
|
||||
|
|
|
|||
45
Sora/Utils/WebAuthentication/WebAuthenticationManager.swift
Normal file
45
Sora/Utils/WebAuthentication/WebAuthenticationManager.swift
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
//
|
||||
// WebAuthenticationManager.swift
|
||||
// Sulfur
|
||||
//
|
||||
// Created by Francesco on 11/06/25.
|
||||
//
|
||||
|
||||
import AuthenticationServices
|
||||
|
||||
class WebAuthenticationManager {
|
||||
static let shared = WebAuthenticationManager()
|
||||
private var webAuthSession: ASWebAuthenticationSession?
|
||||
|
||||
func authenticate(url: URL, callbackScheme: String, completion: @escaping (Result<URL, Error>) -> Void) {
|
||||
webAuthSession = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackScheme) { callbackURL, error in
|
||||
if let error = error {
|
||||
completion(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
if let callbackURL = callbackURL {
|
||||
completion(.success(callbackURL))
|
||||
} else {
|
||||
completion(.failure(NSError(domain: "WebAuthenticationManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Authentication callback URL not received"])))
|
||||
}
|
||||
}
|
||||
|
||||
webAuthSession?.presentationContextProvider = WebAuthenticationPresentationContext.shared
|
||||
webAuthSession?.prefersEphemeralWebBrowserSession = true
|
||||
webAuthSession?.start()
|
||||
}
|
||||
}
|
||||
|
||||
class WebAuthenticationPresentationContext: NSObject, ASWebAuthenticationPresentationContextProviding {
|
||||
static let shared = WebAuthenticationPresentationContext()
|
||||
|
||||
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = windowScene.windows.first else {
|
||||
fatalError("No window found")
|
||||
}
|
||||
|
||||
return window
|
||||
}
|
||||
}
|
||||
|
|
@ -57,16 +57,16 @@ struct DownloadView: View {
|
|||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: selectedTab)
|
||||
.navigationBarHidden(true)
|
||||
.alert("Delete Download", isPresented: $showDeleteAlert) {
|
||||
Button("Delete", role: .destructive) {
|
||||
.alert(NSLocalizedString("Delete Download", comment: ""), isPresented: $showDeleteAlert) {
|
||||
Button(NSLocalizedString("Delete", comment: ""), role: .destructive) {
|
||||
if let asset = assetToDelete {
|
||||
jsController.deleteAsset(asset)
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) {}
|
||||
} message: {
|
||||
if let asset = assetToDelete {
|
||||
Text("Are you sure you want to delete '\(asset.episodeDisplayName)'?")
|
||||
Text(String(format: NSLocalizedString("Are you sure you want to delete '%@'?", comment: ""), asset.episodeDisplayName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -83,7 +83,7 @@ struct DownloadView: View {
|
|||
VStack(spacing: 20) {
|
||||
if !jsController.downloadQueue.isEmpty {
|
||||
DownloadSectionView(
|
||||
title: "Queue",
|
||||
title: NSLocalizedString("Queue", comment: ""),
|
||||
icon: "clock.fill",
|
||||
downloads: jsController.downloadQueue
|
||||
)
|
||||
|
|
@ -91,7 +91,7 @@ struct DownloadView: View {
|
|||
|
||||
if !jsController.activeDownloads.isEmpty {
|
||||
DownloadSectionView(
|
||||
title: "Active Downloads",
|
||||
title: NSLocalizedString("Active Downloads", comment: ""),
|
||||
icon: "arrow.down.circle.fill",
|
||||
downloads: jsController.activeDownloads
|
||||
)
|
||||
|
|
@ -140,12 +140,12 @@ struct DownloadView: View {
|
|||
.foregroundStyle(.tertiary)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("No Active Downloads")
|
||||
Text(NSLocalizedString("No Active Downloads", comment: ""))
|
||||
.font(.title2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("Actively downloading media can be tracked from here.")
|
||||
Text(NSLocalizedString("Actively downloading media can be tracked from here.", comment: ""))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
|
@ -162,12 +162,12 @@ struct DownloadView: View {
|
|||
.foregroundStyle(.tertiary)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("No Downloads")
|
||||
Text(NSLocalizedString("No Downloads", comment: ""))
|
||||
.font(.title2)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("Your downloaded episodes will appear here")
|
||||
Text(NSLocalizedString("Your downloaded episodes will appear here", comment: ""))
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
|
|
@ -274,7 +274,7 @@ struct CustomDownloadHeader: View {
|
|||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text("Downloads")
|
||||
Text(NSLocalizedString("Downloads", comment: ""))
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
.foregroundStyle(.primary)
|
||||
|
|
@ -293,29 +293,15 @@ struct CustomDownloadHeader: View {
|
|||
Image(systemName: isSearchActive ? "xmark.circle.fill" : "magnifyingglass")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 24, height: 24)
|
||||
.frame(width: 18, height: 18)
|
||||
.foregroundColor(.accentColor)
|
||||
.padding(6)
|
||||
.padding(10)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(Color.gray.opacity(0.2))
|
||||
.shadow(color: .accentColor.opacity(0.2), radius: 2)
|
||||
)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.accentColor.opacity(0.25), location: 0),
|
||||
.init(color: Color.accentColor.opacity(0), location: 1)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
.frame(width: 32, height: 32)
|
||||
)
|
||||
.circularGradientOutline()
|
||||
}
|
||||
|
||||
if showSortMenu {
|
||||
|
|
@ -336,28 +322,15 @@ struct CustomDownloadHeader: View {
|
|||
Image(systemName: "arrow.up.arrow.down")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 24, height: 24)
|
||||
.frame(width: 18, height: 18)
|
||||
.foregroundColor(.accentColor)
|
||||
.padding(6)
|
||||
.padding(10)
|
||||
.background(
|
||||
Circle()
|
||||
.fill(Color.gray.opacity(0.2))
|
||||
.shadow(color: .accentColor.opacity(0.2), radius: 2)
|
||||
)
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.accentColor.opacity(0.25), location: 0),
|
||||
.init(color: Color.accentColor.opacity(0), location: 1)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
)
|
||||
.circularGradientOutline()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -370,10 +343,12 @@ struct CustomDownloadHeader: View {
|
|||
HStack(spacing: 12) {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 18, height: 18)
|
||||
.foregroundColor(.secondary)
|
||||
.font(.body)
|
||||
|
||||
TextField("Search downloads", text: $searchText)
|
||||
TextField(NSLocalizedString("Search downloads", comment: ""), text: $searchText)
|
||||
.textFieldStyle(PlainTextFieldStyle())
|
||||
.foregroundColor(.primary)
|
||||
|
||||
|
|
@ -382,8 +357,10 @@ struct CustomDownloadHeader: View {
|
|||
searchText = ""
|
||||
}) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 18, height: 18)
|
||||
.foregroundColor(.secondary)
|
||||
.font(.body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -394,16 +371,16 @@ struct CustomDownloadHeader: View {
|
|||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.accentColor.opacity(0.25), location: 0),
|
||||
.init(color: Color.accentColor.opacity(0), location: 1)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 1.5
|
||||
)
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.accentColor.opacity(0.25), location: 0),
|
||||
.init(color: Color.accentColor.opacity(0), location: 1)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 1.5
|
||||
)
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
|
|
@ -417,14 +394,14 @@ struct CustomDownloadHeader: View {
|
|||
VStack(spacing: 0) {
|
||||
HStack(spacing: 0) {
|
||||
TabButton(
|
||||
title: "Active",
|
||||
title: NSLocalizedString("Active", comment: ""),
|
||||
icon: "arrow.down.circle",
|
||||
isSelected: selectedTab == 0,
|
||||
action: { selectedTab = 0 }
|
||||
)
|
||||
|
||||
TabButton(
|
||||
title: "Downloaded",
|
||||
title: NSLocalizedString("Downloaded", comment: ""),
|
||||
icon: "checkmark.circle",
|
||||
isSelected: selectedTab == 1,
|
||||
action: { selectedTab = 1 }
|
||||
|
|
@ -549,7 +526,7 @@ struct DownloadSummaryCard: View {
|
|||
HStack {
|
||||
Image(systemName: "chart.bar.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
Text("Download Summary".uppercased())
|
||||
Text(NSLocalizedString("Download Summary", comment: "").uppercased())
|
||||
.font(.footnote)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
|
|
@ -561,7 +538,7 @@ struct DownloadSummaryCard: View {
|
|||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack(spacing: 20) {
|
||||
SummaryItem(
|
||||
title: "Shows",
|
||||
title: NSLocalizedString("Shows", comment: ""),
|
||||
value: "\(totalShows)",
|
||||
icon: "tv.fill"
|
||||
)
|
||||
|
|
@ -569,7 +546,7 @@ struct DownloadSummaryCard: View {
|
|||
Divider().frame(height: 32)
|
||||
|
||||
SummaryItem(
|
||||
title: "Episodes",
|
||||
title: NSLocalizedString("Episodes", comment: ""),
|
||||
value: "\(totalEpisodes)",
|
||||
icon: "play.rectangle.fill"
|
||||
)
|
||||
|
|
@ -582,7 +559,7 @@ struct DownloadSummaryCard: View {
|
|||
let sizeUnit = components.dropFirst().first.map(String.init) ?? ""
|
||||
|
||||
SummaryItem(
|
||||
title: "Size (\(sizeUnit))",
|
||||
title: String(format: NSLocalizedString("Size (%@)", comment: ""), sizeUnit),
|
||||
value: sizeValue,
|
||||
icon: "internaldrive.fill"
|
||||
)
|
||||
|
|
@ -617,28 +594,6 @@ struct DownloadSummaryCard: View {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
private func formatFileSize(_ size: Int64) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.allowedUnits = [.useKB, .useMB, .useGB]
|
||||
formatter.countStyle = .file
|
||||
return formatter.string(fromByteCount: size)
|
||||
}
|
||||
|
||||
private func formatFileSizeWithUnit(_ size: Int64) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.allowedUnits = [.useKB, .useMB, .useGB]
|
||||
formatter.countStyle = .file
|
||||
|
||||
let formattedString = formatter.string(fromByteCount: size)
|
||||
let components = formattedString.components(separatedBy: " ")
|
||||
if components.count == 2 {
|
||||
return "Size (\(components[1]))"
|
||||
}
|
||||
return "Size"
|
||||
}
|
||||
|
||||
|
||||
struct SummaryItem: View {
|
||||
let title: String
|
||||
let value: String
|
||||
|
|
@ -675,7 +630,7 @@ struct DownloadedSection: View {
|
|||
HStack {
|
||||
Image(systemName: "folder.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
Text("Downloaded Shows".uppercased())
|
||||
Text(NSLocalizedString("Downloaded Shows", comment: "").uppercased())
|
||||
.font(.footnote)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
|
|
@ -760,7 +715,7 @@ struct EnhancedActiveDownloadCard: View {
|
|||
VStack(spacing: 6) {
|
||||
HStack {
|
||||
if download.queueStatus == .queued {
|
||||
Text("Queued")
|
||||
Text(NSLocalizedString("Queued", comment: ""))
|
||||
.font(.caption)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.orange)
|
||||
|
|
@ -842,11 +797,11 @@ struct EnhancedActiveDownloadCard: View {
|
|||
|
||||
private var statusText: String {
|
||||
if download.queueStatus == .queued {
|
||||
return "Queued"
|
||||
return NSLocalizedString("Queued", comment: "")
|
||||
} else if taskState == .running {
|
||||
return "Downloading"
|
||||
return NSLocalizedString("Downloading", comment: "")
|
||||
} else {
|
||||
return "Paused"
|
||||
return NSLocalizedString("Paused", comment: "")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1071,7 +1026,7 @@ struct EnhancedShowEpisodesView: View {
|
|||
HStack {
|
||||
Image(systemName: "list.bullet.rectangle")
|
||||
.foregroundColor(.accentColor)
|
||||
Text("Episodes".uppercased())
|
||||
Text(NSLocalizedString("Episodes", comment: "").uppercased())
|
||||
.font(.footnote)
|
||||
.fontWeight(.medium)
|
||||
.foregroundColor(.secondary)
|
||||
|
|
@ -1096,7 +1051,7 @@ struct EnhancedShowEpisodesView: View {
|
|||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: episodeSortOption.systemImage)
|
||||
Text("Sort")
|
||||
Text(NSLocalizedString("Sort", comment: ""))
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.accentColor)
|
||||
|
|
@ -1107,7 +1062,7 @@ struct EnhancedShowEpisodesView: View {
|
|||
}) {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "trash")
|
||||
Text("Delete All")
|
||||
Text(NSLocalizedString("Delete All", comment: ""))
|
||||
}
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.red)
|
||||
|
|
@ -1118,7 +1073,7 @@ struct EnhancedShowEpisodesView: View {
|
|||
|
||||
// Episodes List
|
||||
if group.assets.isEmpty {
|
||||
Text("No episodes available")
|
||||
Text(NSLocalizedString("No episodes available", comment: ""))
|
||||
.foregroundColor(.secondary)
|
||||
.italic()
|
||||
.padding(40)
|
||||
|
|
@ -1131,7 +1086,7 @@ struct EnhancedShowEpisodesView: View {
|
|||
)
|
||||
.contextMenu {
|
||||
Button(action: { onPlay(asset) }) {
|
||||
Label("Play", systemImage: "play.fill")
|
||||
Label(NSLocalizedString("Play", comment: ""), systemImage: "play.fill")
|
||||
}
|
||||
.disabled(!asset.fileExists)
|
||||
|
||||
|
|
@ -1139,7 +1094,7 @@ struct EnhancedShowEpisodesView: View {
|
|||
assetToDelete = asset
|
||||
showDeleteAlert = true
|
||||
}) {
|
||||
Label("Delete", systemImage: "trash")
|
||||
Label(NSLocalizedString("Delete", comment: ""), systemImage: "trash")
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
|
|
@ -1152,27 +1107,27 @@ struct EnhancedShowEpisodesView: View {
|
|||
}
|
||||
.padding(.vertical)
|
||||
}
|
||||
.navigationTitle("Episodes")
|
||||
.navigationTitle(NSLocalizedString("Episodes", comment: ""))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.alert("Delete Episode", isPresented: $showDeleteAlert) {
|
||||
Button("Cancel", role: .cancel) { }
|
||||
Button("Delete", role: .destructive) {
|
||||
.alert(NSLocalizedString("Delete Episode", comment: ""), isPresented: $showDeleteAlert) {
|
||||
Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) { }
|
||||
Button(NSLocalizedString("Delete", comment: ""), role: .destructive) {
|
||||
if let asset = assetToDelete {
|
||||
onDelete(asset)
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
if let asset = assetToDelete {
|
||||
Text("Are you sure you want to delete '\(asset.episodeDisplayName)'?")
|
||||
Text(String(format: NSLocalizedString("Are you sure you want to delete '%@'?", comment: ""), asset.episodeDisplayName))
|
||||
}
|
||||
}
|
||||
.alert("Delete All Episodes", isPresented: $showDeleteAllAlert) {
|
||||
Button("Cancel", role: .cancel) { }
|
||||
Button("Delete All", role: .destructive) {
|
||||
.alert(NSLocalizedString("Delete All Episodes", comment: ""), isPresented: $showDeleteAllAlert) {
|
||||
Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) { }
|
||||
Button(NSLocalizedString("Delete All", comment: ""), role: .destructive) {
|
||||
deleteAllAssets()
|
||||
}
|
||||
} message: {
|
||||
Text("Are you sure you want to delete all \(group.assetCount) episodes in '\(group.title)'?")
|
||||
Text(String(format: NSLocalizedString("Are you sure you want to delete all %d episodes in '%@'?", comment: ""), group.assetCount, group.title))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,10 +36,10 @@ struct AllWatchingView: View {
|
|||
@State private var sortOption: SortOption = .dateAdded
|
||||
|
||||
enum SortOption: String, CaseIterable {
|
||||
case dateAdded = "Date Added"
|
||||
case title = "Title"
|
||||
case source = "Source"
|
||||
case progress = "Progress"
|
||||
case dateAdded = "Recently Added"
|
||||
case title = "Series Title"
|
||||
case source = "Content Source"
|
||||
case progress = "Watch Progress"
|
||||
}
|
||||
|
||||
var sortedItems: [ContinueWatchingItem] {
|
||||
|
|
|
|||
|
|
@ -91,9 +91,9 @@ struct LibraryView: View {
|
|||
Image(systemName: "play.circle")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
Text("No items to continue watching.")
|
||||
Text("Nothing to Continue Watching")
|
||||
.font(.headline)
|
||||
Text("Recently watched content will appear here.")
|
||||
Text("Your recently watched content will appear here")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
|
@ -303,8 +303,6 @@ struct ContinueWatchingCell: View {
|
|||
}
|
||||
.overlay(
|
||||
ZStack {
|
||||
ProgressiveBlurView()
|
||||
.cornerRadius(10, corners: [.bottomLeft, .bottomRight])
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Spacer()
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
//
|
||||
// AnilistMatchPopupView.swift
|
||||
// Sulfur
|
||||
//
|
||||
// Created by seiike on 01/06/2025.
|
||||
// AnilistMatchPopupView.swift
|
||||
// Sulfur
|
||||
//
|
||||
// Created by seiike on 01/06/2025.
|
||||
|
||||
import NukeUI
|
||||
import SwiftUI
|
||||
|
||||
struct AnilistMatchPopupView: View {
|
||||
let seriesTitle: String
|
||||
let onSelect: (Int) -> Void
|
||||
let onSelect: (Int, String) -> Void
|
||||
|
||||
@State private var results: [[String: Any]] = []
|
||||
@State private var isLoading = true
|
||||
|
|
@ -43,7 +42,7 @@ struct AnilistMatchPopupView: View {
|
|||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
} else if results.isEmpty {
|
||||
Text("No matches found")
|
||||
Text("No AniList matches found")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.gray)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
|
@ -52,10 +51,11 @@ struct AnilistMatchPopupView: View {
|
|||
LazyVStack(spacing: 15) {
|
||||
ForEach(results.indices, id: \.self) { index in
|
||||
let result = results[index]
|
||||
|
||||
Button(action: {
|
||||
if let id = result["id"] as? Int {
|
||||
onSelect(id)
|
||||
let title = result["title"] as? String ?? seriesTitle
|
||||
onSelect(id, title)
|
||||
dismiss()
|
||||
}
|
||||
}) {
|
||||
HStack(spacing: 12) {
|
||||
|
|
@ -81,7 +81,6 @@ struct AnilistMatchPopupView: View {
|
|||
Text(result["title"] as? String ?? "Unknown")
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
if let english = result["title_english"] as? String {
|
||||
Text(english)
|
||||
.font(.caption)
|
||||
|
|
@ -135,34 +134,32 @@ struct AnilistMatchPopupView: View {
|
|||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
.foregroundColor(isLightMode ? .black : .white)
|
||||
Button("Cancel") { dismiss() }
|
||||
.foregroundColor(isLightMode ? .black : .white)
|
||||
}
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: {
|
||||
Button {
|
||||
manualIDText = ""
|
||||
showingManualIDAlert = true
|
||||
}) {
|
||||
} label: {
|
||||
Image(systemName: "number")
|
||||
.foregroundColor(isLightMode ? .black : .white)
|
||||
}
|
||||
}
|
||||
}
|
||||
.alert("Set Custom AniList ID", isPresented: $showingManualIDAlert, actions: {
|
||||
.alert("Set Custom AniList ID", isPresented: $showingManualIDAlert) {
|
||||
TextField("AniList ID", text: $manualIDText)
|
||||
.keyboardType(.numberPad)
|
||||
Button("Cancel", role: .cancel) { }
|
||||
Button("Save", action: {
|
||||
Button("Save") {
|
||||
if let idInt = Int(manualIDText.trimmingCharacters(in: .whitespaces)) {
|
||||
onSelect(idInt)
|
||||
onSelect(idInt, seriesTitle)
|
||||
dismiss()
|
||||
}
|
||||
})
|
||||
}, message: {
|
||||
Text("Enter the AniList ID for this media")
|
||||
})
|
||||
}
|
||||
} message: {
|
||||
Text("Enter the AniList ID for this series")
|
||||
}
|
||||
}
|
||||
.onAppear(perform: fetchMatches)
|
||||
}
|
||||
|
|
@ -186,7 +183,6 @@ struct AnilistMatchPopupView: View {
|
|||
"""
|
||||
|
||||
guard let url = URL(string: "https://graphql.anilist.co") else { return }
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
|
@ -194,25 +190,23 @@ struct AnilistMatchPopupView: View {
|
|||
|
||||
URLSession.shared.dataTask(with: request) { data, _, _ in
|
||||
DispatchQueue.main.async {
|
||||
self.isLoading = false
|
||||
isLoading = false
|
||||
guard
|
||||
let data = data,
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let dataDict = json["data"] as? [String: Any],
|
||||
let page = dataDict["Page"] as? [String: Any],
|
||||
let mediaList = page["media"] as? [[String: Any]]
|
||||
else { return }
|
||||
|
||||
guard let data = data,
|
||||
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let dataDict = json["data"] as? [String: Any],
|
||||
let page = dataDict["Page"] as? [String: Any],
|
||||
let mediaList = page["media"] as? [[String: Any]] else {
|
||||
return
|
||||
}
|
||||
|
||||
self.results = mediaList.map { media in
|
||||
results = mediaList.map { media in
|
||||
let titleInfo = media["title"] as? [String: Any]
|
||||
let cover = (media["coverImage"] as? [String: Any])?["large"] as? String
|
||||
|
||||
return [
|
||||
"id": media["id"] ?? 0,
|
||||
"title": titleInfo?["romaji"] ?? "Unknown",
|
||||
"title_english": titleInfo?["english"],
|
||||
"cover": cover
|
||||
"title_english": titleInfo?["english"] as Any,
|
||||
"cover": cover as Any
|
||||
]
|
||||
}
|
||||
}
|
||||
170
Sora/Views/MediaInfoView/CustomMatching/TMDBMatchPopupView.swift
Normal file
170
Sora/Views/MediaInfoView/CustomMatching/TMDBMatchPopupView.swift
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
//
|
||||
// TMDBMatchPopupView.swift
|
||||
// Sulfur
|
||||
//
|
||||
// Created by seiike on 12/06/2025.
|
||||
|
||||
import SwiftUI
|
||||
import NukeUI
|
||||
|
||||
struct TMDBMatchPopupView: View {
|
||||
let seriesTitle: String
|
||||
let onSelect: (Int, TMDBFetcher.MediaType, String) -> Void
|
||||
|
||||
@State private var results: [ResultItem] = []
|
||||
@State private var isLoading = true
|
||||
@State private var showingError = false
|
||||
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
struct ResultItem: Identifiable {
|
||||
let id: Int
|
||||
let title: String
|
||||
let mediaType: TMDBFetcher.MediaType
|
||||
let posterURL: String?
|
||||
}
|
||||
|
||||
private struct TMDBSearchResult: Decodable {
|
||||
let id: Int
|
||||
let name: String?
|
||||
let title: String?
|
||||
let poster_path: String?
|
||||
let popularity: Double
|
||||
}
|
||||
|
||||
private struct TMDBSearchResponse: Decodable {
|
||||
let results: [TMDBSearchResult]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
if isLoading {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
} else if results.isEmpty {
|
||||
Text("No matches found")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.gray)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
} else {
|
||||
LazyVStack(spacing: 15) {
|
||||
ForEach(results) { item in
|
||||
Button {
|
||||
onSelect(item.id, item.mediaType, item.title)
|
||||
dismiss()
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
if let poster = item.posterURL, let url = URL(string: poster) {
|
||||
LazyImage(url: url) { state in
|
||||
if let image = state.imageContainer?.image {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 50, height: 75)
|
||||
.cornerRadius(6)
|
||||
} else {
|
||||
Rectangle()
|
||||
.fill(.tertiary)
|
||||
.frame(width: 50, height: 75)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(item.title)
|
||||
.font(.body)
|
||||
.foregroundStyle(.primary)
|
||||
Text(item.mediaType.rawValue.capitalized)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(11)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 15)
|
||||
.fill(.ultraThinMaterial)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 15)
|
||||
.stroke(
|
||||
Color.accentColor.opacity(0.2),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("TMDB Match")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
}
|
||||
.alert("Error Fetching Results", isPresented: $showingError) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
Text("Unable to fetch matches. Please try again later.")
|
||||
}
|
||||
}
|
||||
.onAppear(perform: fetchMatches)
|
||||
}
|
||||
|
||||
private func fetchMatches() {
|
||||
isLoading = true
|
||||
results = []
|
||||
let fetcher = TMDBFetcher()
|
||||
let apiKey = fetcher.apiKey
|
||||
let dispatchGroup = DispatchGroup()
|
||||
var temp: [ResultItem] = []
|
||||
var encounteredError = false
|
||||
|
||||
for type in TMDBFetcher.MediaType.allCases {
|
||||
dispatchGroup.enter()
|
||||
let query = seriesTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
|
||||
let urlString = "https://api.themoviedb.org/3/search/\(type.rawValue)?api_key=\(apiKey)&query=\(query)"
|
||||
guard let url = URL(string: urlString) else {
|
||||
encounteredError = true
|
||||
dispatchGroup.leave()
|
||||
continue
|
||||
}
|
||||
|
||||
URLSession.shared.dataTask(with: url) { data, _, error in
|
||||
defer { dispatchGroup.leave() }
|
||||
guard error == nil,
|
||||
let data = data,
|
||||
let response = try? JSONDecoder().decode(TMDBSearchResponse.self, from: data)
|
||||
else {
|
||||
encounteredError = true
|
||||
return
|
||||
}
|
||||
|
||||
let items = response.results.prefix(6).map { res -> ResultItem in
|
||||
let title = (type == .tv ? res.name : res.title) ?? "Unknown"
|
||||
let poster = res.poster_path.map { "https://image.tmdb.org/t/p/w500\($0)" }
|
||||
return ResultItem(id: res.id, title: title, mediaType: type, posterURL: poster)
|
||||
}
|
||||
temp.append(contentsOf: items)
|
||||
}.resume()
|
||||
}
|
||||
|
||||
dispatchGroup.notify(queue: .main) {
|
||||
if encounteredError { showingError = true }
|
||||
results = Array(temp.prefix(6))
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -27,9 +27,9 @@ struct SearchStateView: View {
|
|||
Image(systemName: "magnifyingglass")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
Text("No Results Found")
|
||||
Text("No Search Results Found")
|
||||
.font(.headline)
|
||||
Text("Try different keywords")
|
||||
Text("Try different search terms")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,6 +117,10 @@ struct ModuleSelectorMenu: View {
|
|||
)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color(.systemGray6).opacity(0))
|
||||
.cornerRadius(12)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,14 +59,12 @@ fileprivate struct SettingsSection<Content: View>: View {
|
|||
}
|
||||
|
||||
struct SettingsViewAbout: View {
|
||||
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "ALPHA"
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
SettingsSection(title: "App Info", footer: "Sora/Sulfur will always remain free with no ADs!") {
|
||||
SettingsSection(title: "App Info", footer: "Sora/Sulfur will always remain free with no ads!") {
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
LazyImage(url: URL(string: "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/Sora/Assets.xcassets/AppIcons/AppIcon_Default.appiconset/darkmode.png")) { state in
|
||||
LazyImage(url: URL(string: "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/Sora/Assets.xcassets/AppIcon.appiconset/darkmode.png")) { state in
|
||||
if let uiImage = state.imageContainer?.image {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
|
|
@ -83,7 +81,7 @@ struct SettingsViewAbout: View {
|
|||
Text("Sora")
|
||||
.font(.title)
|
||||
.bold()
|
||||
Text("AKA Sulfur")
|
||||
Text("Also known as Sulfur")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
|
@ -174,15 +172,34 @@ struct ContributorsView: View {
|
|||
}
|
||||
|
||||
private var filteredContributors: [Contributor] {
|
||||
contributors.filter { contributor in
|
||||
let realContributors = contributors.filter { contributor in
|
||||
!["cranci1", "code-factor"].contains(contributor.login.lowercased())
|
||||
}
|
||||
|
||||
let artificialUsers = createArtificialUsers()
|
||||
|
||||
return realContributors + artificialUsers
|
||||
}
|
||||
|
||||
private func createArtificialUsers() -> [Contributor] {
|
||||
return [
|
||||
Contributor(
|
||||
id: 71751652,
|
||||
login: "qooode",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/71751652?v=4"
|
||||
),
|
||||
Contributor(
|
||||
id: 8116188,
|
||||
login: "undeaDD",
|
||||
avatarUrl: "https://avatars.githubusercontent.com/u/8116188?v=4"
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
private func loadContributors() {
|
||||
let url = URL(string: "https://api.github.com/repos/cranci1/Sora/contributors")!
|
||||
|
||||
URLSession.shared.dataTask(with: url) { data, response, error in
|
||||
URLSession.custom.dataTask(with: url) { data, response, error in
|
||||
DispatchQueue.main.async {
|
||||
isLoading = false
|
||||
|
||||
|
|
|
|||
|
|
@ -153,41 +153,25 @@ struct SettingsViewData: View {
|
|||
return ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
SettingsSection(
|
||||
title: "App Storage",
|
||||
footer: "The app cache allow the app to sho immages faster.\n\nClearing the documents folder will remove all the modules.\n\nThe App Data should never be erased if you don't know what that will cause."
|
||||
title: NSLocalizedString("App Storage", comment: ""),
|
||||
footer: NSLocalizedString("The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nDo not erase App Data unless you understand the consequences — it may cause the app to malfunction.", comment: "")
|
||||
) {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Button(action: {
|
||||
SettingsButtonRow(
|
||||
icon: "trash",
|
||||
title: NSLocalizedString("Remove All Cache", comment: ""),
|
||||
subtitle: cacheSizeText,
|
||||
action: {
|
||||
activeAlert = .clearCache
|
||||
showAlert = true
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "trash")
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.red)
|
||||
|
||||
Text("Remove All Caches")
|
||||
.foregroundStyle(.red)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
|
||||
Text(cacheSizeText)
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
)
|
||||
|
||||
Divider().padding(.horizontal, 16)
|
||||
|
||||
SettingsButtonRow(
|
||||
icon: "film",
|
||||
title: "Remove Downloads",
|
||||
title: NSLocalizedString("Remove Downloads", comment: ""),
|
||||
subtitle: formatSize(downloadsSize),
|
||||
action: {
|
||||
activeAlert = .removeDownloads
|
||||
|
|
@ -199,7 +183,7 @@ struct SettingsViewData: View {
|
|||
|
||||
SettingsButtonRow(
|
||||
icon: "doc.text",
|
||||
title: "Remove All Documents",
|
||||
title: NSLocalizedString("Remove All Documents", comment: ""),
|
||||
subtitle: formatSize(documentsSize),
|
||||
action: {
|
||||
activeAlert = .removeDocs
|
||||
|
|
@ -211,7 +195,7 @@ struct SettingsViewData: View {
|
|||
|
||||
SettingsButtonRow(
|
||||
icon: "exclamationmark.triangle",
|
||||
title: "Erase all App Data",
|
||||
title: NSLocalizedString("Erase all App Data", comment: ""),
|
||||
action: {
|
||||
activeAlert = .eraseData
|
||||
showAlert = true
|
||||
|
|
@ -221,7 +205,7 @@ struct SettingsViewData: View {
|
|||
}
|
||||
}
|
||||
.scrollViewBottomPadding()
|
||||
.navigationTitle("App Data")
|
||||
.navigationTitle(NSLocalizedString("App Data", comment: ""))
|
||||
.onAppear {
|
||||
calculateCacheSize()
|
||||
updateSizes()
|
||||
|
|
@ -231,36 +215,36 @@ struct SettingsViewData: View {
|
|||
switch activeAlert {
|
||||
case .eraseData:
|
||||
return Alert(
|
||||
title: Text("Erase App Data"),
|
||||
message: Text("Are you sure you want to erase all app data? This action cannot be undone."),
|
||||
primaryButton: .destructive(Text("Erase")) {
|
||||
title: Text(NSLocalizedString("Erase App Data", comment: "")),
|
||||
message: Text(NSLocalizedString("Are you sure you want to erase all app data? This action cannot be undone.", comment: "")),
|
||||
primaryButton: .destructive(Text(NSLocalizedString("Erase", comment: ""))) {
|
||||
eraseAppData()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case .removeDocs:
|
||||
return Alert(
|
||||
title: Text("Remove Documents"),
|
||||
message: Text("Are you sure you want to remove all files in the Documents folder? This will remove all modules."),
|
||||
primaryButton: .destructive(Text("Remove")) {
|
||||
title: Text(NSLocalizedString("Remove Documents", comment: "")),
|
||||
message: Text(NSLocalizedString("Are you sure you want to remove all files in the Documents folder? This will remove all modules.", comment: "")),
|
||||
primaryButton: .destructive(Text(NSLocalizedString("Remove", comment: ""))) {
|
||||
removeAllFilesInDocuments()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case .removeDownloads:
|
||||
return Alert(
|
||||
title: Text("Remove Downloaded Media"),
|
||||
message: Text("Are you sure you want to remove all downloaded media files (.mov, .mp4, .pkg)? This action cannot be undone."),
|
||||
primaryButton: .destructive(Text("Remove")) {
|
||||
title: Text(NSLocalizedString("Remove Downloaded Media", comment: "")),
|
||||
message: Text(NSLocalizedString("Are you sure you want to remove all downloaded media files (.mov, .mp4, .pkg)? This action cannot be undone.", comment: "")),
|
||||
primaryButton: .destructive(Text(NSLocalizedString("Remove", comment: ""))) {
|
||||
removeDownloadedMedia()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
case .clearCache:
|
||||
return Alert(
|
||||
title: Text("Clear Cache"),
|
||||
message: Text("Are you sure you want to clear all cached data? This will help free up storage space."),
|
||||
primaryButton: .destructive(Text("Clear")) {
|
||||
title: Text(NSLocalizedString("Clear Cache", comment: "")),
|
||||
message: Text(NSLocalizedString("Are you sure you want to clear all cached data? This will help free up storage space.", comment: "")),
|
||||
primaryButton: .destructive(Text(NSLocalizedString("Clear", comment: ""))) {
|
||||
clearAllCaches()
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
|
|
@ -268,182 +252,183 @@ struct SettingsViewData: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func calculateCacheSize() {
|
||||
isCalculatingSize = true
|
||||
cacheSizeText = "..."
|
||||
|
||||
func calculateCacheSize() {
|
||||
isCalculatingSize = true
|
||||
cacheSizeText = "..."
|
||||
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
if let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
|
||||
let size = calculateDirectorySize(for: cacheURL)
|
||||
DispatchQueue.main.async {
|
||||
self.cacheSize = size
|
||||
self.cacheSizeText = formatSize(size)
|
||||
self.isCalculatingSize = false
|
||||
}
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
if let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
|
||||
let size = calculateDirectorySize(for: cacheURL)
|
||||
DispatchQueue.main.async {
|
||||
self.cacheSize = size
|
||||
self.cacheSizeText = formatSize(size)
|
||||
self.isCalculatingSize = false
|
||||
}
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
self.cacheSizeText = "N/A"
|
||||
self.isCalculatingSize = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateSizes() {
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||||
let size = calculateDirectorySize(for: documentsURL)
|
||||
DispatchQueue.main.async {
|
||||
self.documentsSize = size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func calculateDownloadsSize() {
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||||
let size = calculateMediaFilesSize(in: documentsURL)
|
||||
DispatchQueue.main.async {
|
||||
self.downloadsSize = size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func calculateMediaFilesSize(in directory: URL) -> Int64 {
|
||||
let fileManager = FileManager.default
|
||||
var totalSize: Int64 = 0
|
||||
let mediaExtensions = [".mov", ".mp4", ".pkg"]
|
||||
|
||||
do {
|
||||
let contents = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.fileSizeKey, .isDirectoryKey])
|
||||
for url in contents {
|
||||
let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey])
|
||||
if resourceValues.isDirectory == true {
|
||||
totalSize += calculateMediaFilesSize(in: url)
|
||||
} else {
|
||||
DispatchQueue.main.async {
|
||||
self.cacheSizeText = "N/A"
|
||||
self.isCalculatingSize = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateSizes() {
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||||
let size = calculateDirectorySize(for: documentsURL)
|
||||
DispatchQueue.main.async {
|
||||
self.documentsSize = size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func calculateDownloadsSize() {
|
||||
DispatchQueue.global(qos: .background).async {
|
||||
if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||||
let size = calculateMediaFilesSize(in: documentsURL)
|
||||
DispatchQueue.main.async {
|
||||
self.downloadsSize = size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func calculateMediaFilesSize(in directory: URL) -> Int64 {
|
||||
let fileManager = FileManager.default
|
||||
var totalSize: Int64 = 0
|
||||
let mediaExtensions = [".mov", ".mp4", ".pkg"]
|
||||
|
||||
do {
|
||||
let contents = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.fileSizeKey, .isDirectoryKey])
|
||||
for url in contents {
|
||||
let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey])
|
||||
if resourceValues.isDirectory == true {
|
||||
totalSize += calculateMediaFilesSize(in: url)
|
||||
} else {
|
||||
let fileExtension = url.pathExtension.lowercased()
|
||||
if mediaExtensions.contains(".\(fileExtension)") {
|
||||
totalSize += Int64(resourceValues.fileSize ?? 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Error calculating media files size: \(error)", type: "Error")
|
||||
}
|
||||
|
||||
return totalSize
|
||||
}
|
||||
|
||||
func clearAllCaches() {
|
||||
clearCache()
|
||||
}
|
||||
|
||||
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: [])
|
||||
for filePath in filePaths {
|
||||
try FileManager.default.removeItem(at: filePath)
|
||||
}
|
||||
Logger.shared.log("Cache cleared successfully!", type: "General")
|
||||
calculateCacheSize()
|
||||
updateSizes()
|
||||
calculateDownloadsSize()
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Failed to clear cache.", type: "Error")
|
||||
}
|
||||
}
|
||||
|
||||
func removeDownloadedMedia() {
|
||||
let fileManager = FileManager.default
|
||||
let mediaExtensions = [".mov", ".mp4", ".pkg"]
|
||||
|
||||
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||||
removeMediaFiles(in: documentsURL, extensions: mediaExtensions)
|
||||
Logger.shared.log("Downloaded media files removed", type: "General")
|
||||
updateSizes()
|
||||
calculateDownloadsSize()
|
||||
}
|
||||
}
|
||||
|
||||
func removeMediaFiles(in directory: URL, extensions: [String]) {
|
||||
let fileManager = FileManager.default
|
||||
|
||||
do {
|
||||
let contents = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.isDirectoryKey])
|
||||
for url in contents {
|
||||
let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey])
|
||||
if resourceValues.isDirectory == true {
|
||||
removeMediaFiles(in: url, extensions: extensions)
|
||||
} else {
|
||||
let fileExtension = ".\(url.pathExtension.lowercased())"
|
||||
if extensions.contains(fileExtension) {
|
||||
try fileManager.removeItem(at: url)
|
||||
Logger.shared.log("Removed media file: \(url.lastPathComponent)", type: "General")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Error removing media files in \(directory.path): \(error)", type: "Error")
|
||||
}
|
||||
}
|
||||
|
||||
func removeAllFilesInDocuments() {
|
||||
let fileManager = FileManager.default
|
||||
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||||
do {
|
||||
let fileURLs = try fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil)
|
||||
for fileURL in fileURLs {
|
||||
try fileManager.removeItem(at: fileURL)
|
||||
}
|
||||
Logger.shared.log("All files in documents folder removed", type: "General")
|
||||
exit(0)
|
||||
} catch {
|
||||
Logger.shared.log("Error removing files in documents folder: \(error)", type: "Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func eraseAppData() {
|
||||
if let domain = Bundle.main.bundleIdentifier {
|
||||
UserDefaults.standard.removePersistentDomain(forName: domain)
|
||||
UserDefaults.standard.synchronize()
|
||||
Logger.shared.log("Cleared app data!", type: "General")
|
||||
exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
func calculateDirectorySize(for url: URL) -> Int64 {
|
||||
let fileManager = FileManager.default
|
||||
var totalSize: Int64 = 0
|
||||
|
||||
do {
|
||||
let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.fileSizeKey])
|
||||
for url in contents {
|
||||
let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey])
|
||||
if resourceValues.isDirectory == true {
|
||||
totalSize += calculateDirectorySize(for: url)
|
||||
} else {
|
||||
let fileExtension = url.pathExtension.lowercased()
|
||||
if mediaExtensions.contains(".\(fileExtension)") {
|
||||
totalSize += Int64(resourceValues.fileSize ?? 0)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Error calculating directory size: \(error)", type: "Error")
|
||||
}
|
||||
|
||||
return totalSize
|
||||
} catch {
|
||||
Logger.shared.log("Error calculating media files size: \(error)", type: "Error")
|
||||
}
|
||||
|
||||
func formatSize(_ bytes: Int64) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB]
|
||||
formatter.countStyle = .file
|
||||
return formatter.string(fromByteCount: bytes)
|
||||
return totalSize
|
||||
}
|
||||
|
||||
func clearAllCaches() {
|
||||
clearCache()
|
||||
}
|
||||
|
||||
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: [])
|
||||
for filePath in filePaths {
|
||||
try FileManager.default.removeItem(at: filePath)
|
||||
}
|
||||
Logger.shared.log("Cache cleared successfully!", type: "General")
|
||||
calculateCacheSize()
|
||||
updateSizes()
|
||||
calculateDownloadsSize()
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Failed to clear cache.", type: "Error")
|
||||
}
|
||||
}
|
||||
|
||||
func removeDownloadedMedia() {
|
||||
let fileManager = FileManager.default
|
||||
let mediaExtensions = [".mov", ".mp4", ".pkg"]
|
||||
|
||||
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||||
removeMediaFiles(in: documentsURL, extensions: mediaExtensions)
|
||||
Logger.shared.log("Downloaded media files removed", type: "General")
|
||||
updateSizes()
|
||||
calculateDownloadsSize()
|
||||
}
|
||||
}
|
||||
|
||||
func removeMediaFiles(in directory: URL, extensions: [String]) {
|
||||
let fileManager = FileManager.default
|
||||
|
||||
do {
|
||||
let contents = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.isDirectoryKey])
|
||||
for url in contents {
|
||||
let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey])
|
||||
if resourceValues.isDirectory == true {
|
||||
removeMediaFiles(in: url, extensions: extensions)
|
||||
} else {
|
||||
let fileExtension = ".\(url.pathExtension.lowercased())"
|
||||
if extensions.contains(fileExtension) {
|
||||
try fileManager.removeItem(at: url)
|
||||
Logger.shared.log("Removed media file: \(url.lastPathComponent)", type: "General")
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Error removing media files in \(directory.path): \(error)", type: "Error")
|
||||
}
|
||||
}
|
||||
|
||||
func removeAllFilesInDocuments() {
|
||||
let fileManager = FileManager.default
|
||||
if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first {
|
||||
do {
|
||||
let fileURLs = try fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil)
|
||||
for fileURL in fileURLs {
|
||||
try fileManager.removeItem(at: fileURL)
|
||||
}
|
||||
Logger.shared.log("All files in documents folder removed", type: "General")
|
||||
exit(0)
|
||||
} catch {
|
||||
Logger.shared.log("Error removing files in documents folder: \(error)", type: "Error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func eraseAppData() {
|
||||
if let domain = Bundle.main.bundleIdentifier {
|
||||
UserDefaults.standard.removePersistentDomain(forName: domain)
|
||||
UserDefaults.standard.synchronize()
|
||||
Logger.shared.log("Cleared app data!", type: "General")
|
||||
exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
func calculateDirectorySize(for url: URL) -> Int64 {
|
||||
let fileManager = FileManager.default
|
||||
var totalSize: Int64 = 0
|
||||
|
||||
do {
|
||||
let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.fileSizeKey])
|
||||
for url in contents {
|
||||
let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey])
|
||||
if resourceValues.isDirectory == true {
|
||||
totalSize += calculateDirectorySize(for: url)
|
||||
} else {
|
||||
totalSize += Int64(resourceValues.fileSize ?? 0)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Error calculating directory size: \(error)", type: "Error")
|
||||
}
|
||||
|
||||
return totalSize
|
||||
}
|
||||
|
||||
func formatSize(_ bytes: Int64) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB]
|
||||
formatter.countStyle = .file
|
||||
return formatter.string(fromByteCount: bytes)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -164,12 +164,12 @@ struct SettingsViewDownloads: View {
|
|||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
SettingsSection(
|
||||
title: "Download Settings",
|
||||
footer: "Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources."
|
||||
title: String(localized: "Download Settings"),
|
||||
footer: String(localized: "Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources.")
|
||||
) {
|
||||
SettingsPickerRow(
|
||||
icon: "4k.tv",
|
||||
title: "Quality",
|
||||
title: String(localized: "Quality"),
|
||||
options: DownloadQualityPreference.allCases.map { $0.rawValue },
|
||||
optionToString: { $0 },
|
||||
selection: $downloadQuality
|
||||
|
|
@ -181,7 +181,7 @@ struct SettingsViewDownloads: View {
|
|||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("Max Concurrent Downloads")
|
||||
Text(String(localized: "Max Concurrent Downloads"))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
|
@ -200,14 +200,14 @@ struct SettingsViewDownloads: View {
|
|||
|
||||
SettingsToggleRow(
|
||||
icon: "antenna.radiowaves.left.and.right",
|
||||
title: "Allow Cellular Downloads",
|
||||
title: String(localized: "Allow Cellular Downloads"),
|
||||
isOn: $allowCellularDownloads,
|
||||
showDivider: false
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSection(
|
||||
title: "Quality Information"
|
||||
title: String(localized: "Quality Information")
|
||||
) {
|
||||
if let preferenceDescription = DownloadQualityPreference(rawValue: downloadQuality)?.description {
|
||||
HStack {
|
||||
|
|
@ -222,7 +222,7 @@ struct SettingsViewDownloads: View {
|
|||
}
|
||||
|
||||
SettingsSection(
|
||||
title: "Storage Management"
|
||||
title: String(localized: "Storage Management")
|
||||
) {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
|
|
@ -230,7 +230,7 @@ struct SettingsViewDownloads: View {
|
|||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("Storage Used")
|
||||
Text(String(localized: "Storage Used"))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
|
@ -255,7 +255,7 @@ struct SettingsViewDownloads: View {
|
|||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("Files Downloaded")
|
||||
Text(String(localized: "Files Downloaded"))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
|
@ -277,7 +277,7 @@ struct SettingsViewDownloads: View {
|
|||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("Refresh Storage Info")
|
||||
Text(String(localized: "Refresh Storage Info"))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
|
@ -297,7 +297,7 @@ struct SettingsViewDownloads: View {
|
|||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.red)
|
||||
|
||||
Text("Clear All Downloads")
|
||||
Text(String(localized: "Clear All Downloads"))
|
||||
.foregroundStyle(.red)
|
||||
|
||||
Spacer()
|
||||
|
|
@ -310,18 +310,18 @@ struct SettingsViewDownloads: View {
|
|||
}
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
.navigationTitle("Downloads")
|
||||
.navigationTitle(String(localized: "Downloads"))
|
||||
.scrollViewBottomPadding()
|
||||
.alert("Delete All Downloads", isPresented: $showClearConfirmation) {
|
||||
Button("Cancel", role: .cancel) { }
|
||||
Button("Delete All", role: .destructive) {
|
||||
.alert(String(localized: "Delete All Downloads"), isPresented: $showClearConfirmation) {
|
||||
Button(String(localized: "Cancel"), role: .cancel) { }
|
||||
Button(String(localized: "Delete All"), role: .destructive) {
|
||||
clearAllDownloads(preservePersistentDownloads: false)
|
||||
}
|
||||
Button("Clear Library Only", role: .destructive) {
|
||||
Button(String(localized: "Clear Library Only"), role: .destructive) {
|
||||
clearAllDownloads(preservePersistentDownloads: true)
|
||||
}
|
||||
} message: {
|
||||
Text("Are you sure you want to delete all downloaded assets? You can choose to clear only the library while preserving the downloaded files for future use.")
|
||||
Text(String(localized: "Are you sure you want to delete all downloaded assets? You can choose to clear only the library while preserving the downloaded files for future use."))
|
||||
}
|
||||
.onAppear {
|
||||
calculateTotalStorage()
|
||||
|
|
@ -370,9 +370,9 @@ struct SettingsViewDownloads: View {
|
|||
|
||||
DispatchQueue.main.async {
|
||||
if preservePersistentDownloads {
|
||||
DropManager.shared.success("Library cleared successfully")
|
||||
DropManager.shared.success(String(localized: "Library cleared successfully"))
|
||||
} else {
|
||||
DropManager.shared.success("All downloads deleted successfully")
|
||||
DropManager.shared.success(String(localized: "All downloads deleted successfully"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -153,42 +153,71 @@ struct SettingsViewGeneral: View {
|
|||
@AppStorage("refreshModulesOnLaunch") private var refreshModulesOnLaunch: Bool = true
|
||||
@AppStorage("fetchEpisodeMetadata") private var fetchEpisodeMetadata: Bool = true
|
||||
@AppStorage("analyticsEnabled") private var analyticsEnabled: Bool = false
|
||||
@AppStorage("metadataProviders") private var metadataProviders: String = "TMDB"
|
||||
@AppStorage("hideSplashScreen") private var hideSplashScreenEnable: Bool = false
|
||||
@AppStorage("metadataProvidersOrder") private var metadataProvidersOrderData: Data = {
|
||||
try! JSONEncoder().encode(["TMDB","AniList"])
|
||||
}()
|
||||
@AppStorage("tmdbImageWidth") private var TMDBimageWidht: String = "original"
|
||||
@AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2
|
||||
@AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4
|
||||
@AppStorage("metadataProviders") private var metadataProviders: String = "TMDB"
|
||||
|
||||
private let metadataProvidersList = ["AniList", "TMDB"]
|
||||
private var metadataProvidersOrder: [String] {
|
||||
get { (try? JSONDecoder().decode([String].self, from: metadataProvidersOrderData)) ?? ["AniList","TMDB"] }
|
||||
set { metadataProvidersOrderData = try! JSONEncoder().encode(newValue) }
|
||||
}
|
||||
private let TMDBimageWidhtList = ["300", "500", "780", "1280", "original"]
|
||||
private let sortOrderOptions = ["Ascending", "Descending"]
|
||||
private let metadataProvidersList = ["TMDB", "AniList"]
|
||||
@EnvironmentObject var settings: Settings
|
||||
@State private var showRestartAlert = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
SettingsSection(title: "Interface") {
|
||||
SettingsSection(title: NSLocalizedString("Interface", comment: "")) {
|
||||
SettingsPickerRow(
|
||||
icon: "paintbrush",
|
||||
title: "Appearance",
|
||||
title: NSLocalizedString("Appearance", comment: ""),
|
||||
options: [Appearance.system, .light, .dark],
|
||||
optionToString: { appearance in
|
||||
switch appearance {
|
||||
case .system: return "System"
|
||||
case .light: return "Light"
|
||||
case .dark: return "Dark"
|
||||
case .system: return NSLocalizedString("System", comment: "")
|
||||
case .light: return NSLocalizedString("Light", comment: "")
|
||||
case .dark: return NSLocalizedString("Dark", comment: "")
|
||||
}
|
||||
},
|
||||
selection: $settings.selectedAppearance
|
||||
)
|
||||
|
||||
SettingsToggleRow(
|
||||
icon: "wand.and.rays.inverse",
|
||||
title: NSLocalizedString("Hide Splash Screen", comment: ""),
|
||||
isOn: $hideSplashScreenEnable,
|
||||
showDivider: false
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSection(title: NSLocalizedString("Language", comment: "")) {
|
||||
SettingsPickerRow(
|
||||
icon: "globe",
|
||||
title: NSLocalizedString("App Language", comment: ""),
|
||||
options: ["English", "Dutch"],
|
||||
optionToString: { $0 },
|
||||
selection: $settings.selectedLanguage
|
||||
)
|
||||
.onChange(of: settings.selectedLanguage) { _ in
|
||||
showRestartAlert = true
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection(
|
||||
title: "Media View",
|
||||
footer: "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 refers to the episode thumbnail and title, since sometimes it can contain spoilers."
|
||||
title: NSLocalizedString("Media View", comment: ""),
|
||||
footer: NSLocalizedString("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 refers to the episode thumbnail and title, since sometimes it can contain spoilers.", comment: "")
|
||||
) {
|
||||
SettingsPickerRow(
|
||||
icon: "list.number",
|
||||
title: "Episodes Range",
|
||||
title: NSLocalizedString("Episodes Range", comment: ""),
|
||||
options: [25, 50, 75, 100],
|
||||
optionToString: { "\($0)" },
|
||||
selection: $episodeChunkSize
|
||||
|
|
@ -196,47 +225,67 @@ struct SettingsViewGeneral: View {
|
|||
|
||||
SettingsToggleRow(
|
||||
icon: "info.circle",
|
||||
title: "Fetch Episode metadata",
|
||||
title: NSLocalizedString("Fetch Episode metadata", comment: ""),
|
||||
isOn: $fetchEpisodeMetadata
|
||||
)
|
||||
|
||||
if metadataProviders == "TMDB" {
|
||||
SettingsPickerRow(
|
||||
icon: "server.rack",
|
||||
title: "Metadata Provider",
|
||||
options: metadataProvidersList,
|
||||
optionToString: { $0 },
|
||||
selection: $metadataProviders,
|
||||
showDivider: true
|
||||
)
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.up.arrow.down")
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(NSLocalizedString("Metadata Providers Order", comment: ""))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
SettingsPickerRow(
|
||||
icon: "square.stack.3d.down.right",
|
||||
title: "Thumbnails Width",
|
||||
options: TMDBimageWidhtList,
|
||||
optionToString: { $0 },
|
||||
selection: $TMDBimageWidht,
|
||||
showDivider: false
|
||||
)
|
||||
} else {
|
||||
SettingsPickerRow(
|
||||
icon: "server.rack",
|
||||
title: "Metadata Provider",
|
||||
options: metadataProvidersList,
|
||||
optionToString: { $0 },
|
||||
selection: $metadataProviders,
|
||||
showDivider: false
|
||||
)
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
List {
|
||||
ForEach(Array(metadataProvidersOrder.enumerated()), id: \.element) { index, provider in
|
||||
HStack {
|
||||
Text("\(index + 1)")
|
||||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(.gray)
|
||||
|
||||
Text(provider)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowSeparator(.visible)
|
||||
.listRowSeparatorTint(.gray.opacity(0.3))
|
||||
.listRowInsets(EdgeInsets())
|
||||
}
|
||||
.onMove { from, to in
|
||||
var arr = metadataProvidersOrder
|
||||
arr.move(fromOffsets: from, toOffset: to)
|
||||
metadataProvidersOrderData = try! JSONEncoder().encode(arr)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.frame(height: CGFloat(metadataProvidersOrder.count * 48))
|
||||
.background(Color.clear)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
.environment(\.editMode, .constant(.active))
|
||||
}
|
||||
|
||||
SettingsSection(
|
||||
title: "Media Grid Layout",
|
||||
footer: "Adjust the number of media items per row in portrait and landscape modes."
|
||||
title: NSLocalizedString("Media Grid Layout", comment: ""),
|
||||
footer: NSLocalizedString("Adjust the number of media items per row in portrait and landscape modes.", comment: "")
|
||||
) {
|
||||
SettingsPickerRow(
|
||||
icon: "rectangle.portrait",
|
||||
title: "Portrait Columns",
|
||||
title: NSLocalizedString("Portrait Columns", comment: ""),
|
||||
options: UIDevice.current.userInterfaceIdiom == .pad ? Array(1...5) : Array(1...4),
|
||||
optionToString: { "\($0)" },
|
||||
selection: $mediaColumnsPortrait
|
||||
|
|
@ -244,7 +293,7 @@ struct SettingsViewGeneral: View {
|
|||
|
||||
SettingsPickerRow(
|
||||
icon: "rectangle",
|
||||
title: "Landscape Columns",
|
||||
title: NSLocalizedString("Landscape Columns", comment: ""),
|
||||
options: UIDevice.current.userInterfaceIdiom == .pad ? Array(2...8) : Array(2...5),
|
||||
optionToString: { "\($0)" },
|
||||
selection: $mediaColumnsLandscape,
|
||||
|
|
@ -253,32 +302,40 @@ struct SettingsViewGeneral: View {
|
|||
}
|
||||
|
||||
SettingsSection(
|
||||
title: "Modules",
|
||||
footer: "Note that the modules will be replaced only if there is a different version string inside the JSON file."
|
||||
title: NSLocalizedString("Modules", comment: ""),
|
||||
footer: NSLocalizedString("Note that the modules will be replaced only if there is a different version string inside the JSON file.", comment: "")
|
||||
) {
|
||||
SettingsToggleRow(
|
||||
icon: "arrow.clockwise",
|
||||
title: "Refresh Modules on Launch",
|
||||
title: NSLocalizedString("Refresh Modules on Launch", comment: ""),
|
||||
isOn: $refreshModulesOnLaunch,
|
||||
showDivider: false
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSection(
|
||||
title: "Advanced",
|
||||
footer: "Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time."
|
||||
title: NSLocalizedString("Advanced", comment: ""),
|
||||
footer: NSLocalizedString("Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time.", comment: "")
|
||||
) {
|
||||
SettingsToggleRow(
|
||||
icon: "chart.bar",
|
||||
title: "Enable Analytics",
|
||||
title: NSLocalizedString("Enable Analytics", comment: ""),
|
||||
isOn: $analyticsEnabled,
|
||||
showDivider: false
|
||||
)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 20)
|
||||
.navigationTitle("General")
|
||||
.scrollViewBottomPadding()
|
||||
}
|
||||
.navigationTitle("General")
|
||||
.navigationTitle(NSLocalizedString("General", comment: ""))
|
||||
.scrollViewBottomPadding()
|
||||
.alert(isPresented: $showRestartAlert) {
|
||||
Alert(
|
||||
title: Text(NSLocalizedString("Restart Required", comment: "")),
|
||||
message: Text(NSLocalizedString("Please restart the app to apply the language change.", comment: "")),
|
||||
dismissButton: .default(Text("OK"))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,12 +76,12 @@ struct SettingsViewLogger: View {
|
|||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
SettingsSection(title: "Logs") {
|
||||
SettingsSection(title: NSLocalizedString("Logs", comment: "")) {
|
||||
if isLoading {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
Text("Loading logs...")
|
||||
Text(NSLocalizedString("Loading logs...", comment: ""))
|
||||
.font(.footnote)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
|
@ -99,7 +99,7 @@ struct SettingsViewLogger: View {
|
|||
Button(action: {
|
||||
showFullLogs = true
|
||||
}) {
|
||||
Text("Show More (\(logs.count - displayCharacterLimit) more characters)")
|
||||
Text(NSLocalizedString("Show More (%lld more characters)", comment: "").replacingOccurrences(of: "%lld", with: "\(logs.count - displayCharacterLimit)"))
|
||||
.font(.footnote)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
|
|
@ -113,7 +113,7 @@ struct SettingsViewLogger: View {
|
|||
}
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
.navigationTitle("Logs")
|
||||
.navigationTitle(NSLocalizedString("Logs", comment: ""))
|
||||
.onAppear {
|
||||
loadLogsAsync()
|
||||
}
|
||||
|
|
@ -123,14 +123,14 @@ struct SettingsViewLogger: View {
|
|||
Menu {
|
||||
Button(action: {
|
||||
UIPasteboard.general.string = logs
|
||||
DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill"))
|
||||
DropManager.shared.showDrop(title: NSLocalizedString("Copied to Clipboard", comment: ""), subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill"))
|
||||
}) {
|
||||
Label("Copy to Clipboard", systemImage: "doc.on.doc")
|
||||
Label(NSLocalizedString("Copy to Clipboard", comment: ""), systemImage: "doc.on.doc")
|
||||
}
|
||||
Button(role: .destructive, action: {
|
||||
clearLogsAsync()
|
||||
}) {
|
||||
Label("Clear Logs", systemImage: "trash")
|
||||
Label(NSLocalizedString("Clear Logs", comment: ""), systemImage: "trash")
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
|
|
|
|||
|
|
@ -115,11 +115,11 @@ class LogFilterViewModel: ObservableObject {
|
|||
|
||||
private let userDefaultsKey = "LogFilterStates"
|
||||
private let hardcodedFilters: [(type: String, description: String, defaultState: Bool)] = [
|
||||
("General", "General events and activities.", true),
|
||||
("Stream", "Streaming and video playback.", true),
|
||||
("Error", "Errors and critical issues.", true),
|
||||
("Debug", "Debugging and troubleshooting.", false),
|
||||
("Download", "HLS video downloading.", true),
|
||||
(NSLocalizedString("General", comment: ""), NSLocalizedString("General events and activities.", comment: ""), true),
|
||||
(NSLocalizedString("Stream", comment: ""), NSLocalizedString("Streaming and video playback.", comment: ""), true),
|
||||
(NSLocalizedString("Error", comment: ""), NSLocalizedString("Errors and critical issues.", comment: ""), true),
|
||||
(NSLocalizedString("Debug", comment: ""), NSLocalizedString("Debugging and troubleshooting.", comment: ""), false),
|
||||
(NSLocalizedString("Download", comment: ""), NSLocalizedString("HLS video downloading.", comment: ""), true),
|
||||
("HTMLStrings", "", false)
|
||||
]
|
||||
|
||||
|
|
@ -179,7 +179,7 @@ struct SettingsViewLoggerFilter: View {
|
|||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
SettingsSection(title: "Log Types") {
|
||||
SettingsSection(title: NSLocalizedString("Log Types", comment: "")) {
|
||||
ForEach($viewModel.filters) { $filter in
|
||||
SettingsToggleRow(
|
||||
icon: iconForFilter(filter.type),
|
||||
|
|
@ -192,6 +192,6 @@ struct SettingsViewLoggerFilter: View {
|
|||
}
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
.navigationTitle("Log Filters")
|
||||
.navigationTitle(NSLocalizedString("Log Filters", comment: ""))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,16 +119,16 @@ fileprivate struct ModuleListItemView: View {
|
|||
.contextMenu {
|
||||
Button(action: {
|
||||
UIPasteboard.general.string = module.metadataUrl
|
||||
DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill"))
|
||||
DropManager.shared.showDrop(title: NSLocalizedString("Copied to Clipboard", comment: ""), subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill"))
|
||||
}) {
|
||||
Label("Copy URL", systemImage: "doc.on.doc")
|
||||
Label(NSLocalizedString("Copy URL", comment: ""), systemImage: "doc.on.doc")
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
if selectedModuleId != module.id.uuidString {
|
||||
onDelete()
|
||||
}
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
Label(NSLocalizedString("Delete", comment: ""), systemImage: "trash")
|
||||
}
|
||||
.disabled(selectedModuleId == module.id.uuidString)
|
||||
}
|
||||
|
|
@ -137,7 +137,7 @@ fileprivate struct ModuleListItemView: View {
|
|||
Button(role: .destructive) {
|
||||
onDelete()
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
Label(NSLocalizedString("Delete", comment: ""), systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -163,25 +163,25 @@ struct SettingsViewModule: View {
|
|||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
if moduleManager.modules.isEmpty {
|
||||
SettingsSection(title: "Modules") {
|
||||
SettingsSection(title: NSLocalizedString("Modules", comment: "")) {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "plus.app")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.secondary)
|
||||
Text("No Modules")
|
||||
Text(NSLocalizedString("No Modules", comment: ""))
|
||||
.font(.headline)
|
||||
|
||||
if didReceiveDefaultPageLink {
|
||||
NavigationLink(destination: CommunityLibraryView()
|
||||
.environmentObject(moduleManager)) {
|
||||
Text("Check out some community modules here!")
|
||||
Text(NSLocalizedString("Check out some community modules here!", comment: ""))
|
||||
.font(.caption)
|
||||
.foregroundColor(.accentColor)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
} else {
|
||||
Text("Click the plus button to add a module!")
|
||||
Text(NSLocalizedString("Click the plus button to add a module!", comment: ""))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
|
|
@ -191,14 +191,14 @@ struct SettingsViewModule: View {
|
|||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
} else {
|
||||
SettingsSection(title: "Installed Modules") {
|
||||
SettingsSection(title: NSLocalizedString("Installed Modules", comment: "")) {
|
||||
ForEach(moduleManager.modules) { module in
|
||||
ModuleListItemView(
|
||||
module: module,
|
||||
selectedModuleId: selectedModuleId,
|
||||
onDelete: {
|
||||
moduleManager.deleteModule(module)
|
||||
DropManager.shared.showDrop(title: "Module Removed", subtitle: "", duration: 1.0, icon: UIImage(systemName: "trash"))
|
||||
DropManager.shared.showDrop(title: NSLocalizedString("Module Removed", comment: ""), subtitle: "", duration: 1.0, icon: UIImage(systemName: "trash"))
|
||||
},
|
||||
onSelect: {
|
||||
selectedModuleId = module.id.uuidString
|
||||
|
|
@ -216,7 +216,7 @@ struct SettingsViewModule: View {
|
|||
.padding(.vertical, 20)
|
||||
}
|
||||
.scrollViewBottomPadding()
|
||||
.navigationTitle("Modules")
|
||||
.navigationTitle(NSLocalizedString("Modules", comment: ""))
|
||||
.navigationBarItems(trailing:
|
||||
HStack(spacing: 16) {
|
||||
if didReceiveDefaultPageLink {
|
||||
|
|
@ -228,7 +228,7 @@ struct SettingsViewModule: View {
|
|||
.frame(width: 20, height: 20)
|
||||
.padding(5)
|
||||
}
|
||||
.accessibilityLabel("Open Community Library")
|
||||
.accessibilityLabel(NSLocalizedString("Open Community Library", comment: ""))
|
||||
}
|
||||
|
||||
Button(action: {
|
||||
|
|
@ -239,7 +239,7 @@ struct SettingsViewModule: View {
|
|||
.frame(width: 20, height: 20)
|
||||
.padding(5)
|
||||
}
|
||||
.accessibilityLabel("Add Module")
|
||||
.accessibilityLabel(NSLocalizedString("Add Module", comment: ""))
|
||||
}
|
||||
)
|
||||
.background(
|
||||
|
|
|
|||
|
|
@ -205,18 +205,22 @@ struct SettingsViewPlayer: View {
|
|||
@AppStorage("skipIntroOutroVisible") private var skipIntroOutroVisible: Bool = true
|
||||
@AppStorage("pipButtonVisible") private var pipButtonVisible: Bool = true
|
||||
|
||||
@AppStorage("videoQualityWiFi") private var wifiQuality: String = VideoQualityPreference.defaultWiFiPreference.rawValue
|
||||
@AppStorage("videoQualityCellular") private var cellularQuality: String = VideoQualityPreference.defaultCellularPreference.rawValue
|
||||
|
||||
private let mediaPlayers = ["Default", "Sora", "VLC", "OutPlayer", "Infuse", "nPlayer", "SenPlayer", "IINA", "TracyPlayer"]
|
||||
private let qualityOptions = VideoQualityPreference.allCases.map { $0.rawValue }
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
SettingsSection(
|
||||
title: "Media Player",
|
||||
footer: "Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments."
|
||||
title: NSLocalizedString("Media Player", comment: ""),
|
||||
footer: NSLocalizedString("Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.", comment: "")
|
||||
) {
|
||||
SettingsPickerRow(
|
||||
icon: "play.circle",
|
||||
title: "Media Player",
|
||||
title: NSLocalizedString("Media Player", comment: ""),
|
||||
options: mediaPlayers,
|
||||
optionToString: { $0 },
|
||||
selection: $externalPlayer
|
||||
|
|
@ -224,35 +228,35 @@ struct SettingsViewPlayer: View {
|
|||
|
||||
SettingsToggleRow(
|
||||
icon: "rotate.right",
|
||||
title: "Force Landscape",
|
||||
title: NSLocalizedString("Force Landscape", comment: ""),
|
||||
isOn: $isAlwaysLandscape
|
||||
)
|
||||
|
||||
SettingsToggleRow(
|
||||
icon: "hand.tap",
|
||||
title: "Two Finger Hold for Pause",
|
||||
title: NSLocalizedString("Two Finger Hold for Pause", comment: ""),
|
||||
isOn: $holdForPauseEnabled,
|
||||
showDivider: true
|
||||
)
|
||||
|
||||
SettingsToggleRow(
|
||||
icon: "pip",
|
||||
title: "Show PiP Button",
|
||||
title: NSLocalizedString("Show PiP Button", comment: ""),
|
||||
isOn: $pipButtonVisible,
|
||||
showDivider: false
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSection(title: "Speed Settings") {
|
||||
SettingsSection(title: NSLocalizedString("Speed Settings", comment: "")) {
|
||||
SettingsToggleRow(
|
||||
icon: "speedometer",
|
||||
title: "Remember Playback speed",
|
||||
title: NSLocalizedString("Remember Playback speed", comment: ""),
|
||||
isOn: $isRememberPlaySpeed
|
||||
)
|
||||
|
||||
SettingsStepperRow(
|
||||
icon: "forward.fill",
|
||||
title: "Hold Speed",
|
||||
title: NSLocalizedString("Hold Speed", comment: ""),
|
||||
value: $holdSpeedPlayer,
|
||||
range: 0.25...2.5,
|
||||
step: 0.25,
|
||||
|
|
@ -260,9 +264,30 @@ struct SettingsViewPlayer: View {
|
|||
showDivider: false
|
||||
)
|
||||
}
|
||||
SettingsSection(
|
||||
title: String(localized: "Video Quality Preferences"),
|
||||
footer: String(localized: "Choose preferred video resolution for WiFi and cellular connections. Higher resolutions use more data but provide better quality. If the exact quality isn't available, the closest option will be selected automatically.\n\nNote: Not all video sources and players support quality selection. This feature works best with HLS streams using the Sora player.")
|
||||
) {
|
||||
SettingsPickerRow(
|
||||
icon: "wifi",
|
||||
title: String(localized: "WiFi Quality"),
|
||||
options: qualityOptions,
|
||||
optionToString: { $0 },
|
||||
selection: $wifiQuality
|
||||
)
|
||||
|
||||
SettingsPickerRow(
|
||||
icon: "antenna.radiowaves.left.and.right",
|
||||
title: String(localized: "Cellular Quality"),
|
||||
options: qualityOptions,
|
||||
optionToString: { $0 },
|
||||
selection: $cellularQuality,
|
||||
showDivider: false
|
||||
)
|
||||
}
|
||||
|
||||
SettingsSection(title: "Progress bar Marker Color") {
|
||||
ColorPicker("Segments Color", selection: Binding(
|
||||
SettingsSection(title: NSLocalizedString("Progress bar Marker Color", comment: "")) {
|
||||
ColorPicker(NSLocalizedString("Segments Color", comment: ""), selection: Binding(
|
||||
get: {
|
||||
if let data = UserDefaults.standard.data(forKey: "segmentsColorData"),
|
||||
let uiColor = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? UIColor {
|
||||
|
|
@ -285,12 +310,12 @@ struct SettingsViewPlayer: View {
|
|||
}
|
||||
|
||||
SettingsSection(
|
||||
title: "Skip Settings",
|
||||
footer: "Double tapping the screen on it's sides will skip with the short tap setting."
|
||||
title: NSLocalizedString("Skip Settings", comment: ""),
|
||||
footer: NSLocalizedString("Double tapping the screen on it's sides will skip with the short tap setting.", comment: "")
|
||||
) {
|
||||
SettingsStepperRow(
|
||||
icon: "goforward",
|
||||
title: "Tap Skip",
|
||||
title: NSLocalizedString("Tap Skip", comment: ""),
|
||||
value: $skipIncrement,
|
||||
range: 5...300,
|
||||
step: 5,
|
||||
|
|
@ -299,7 +324,7 @@ struct SettingsViewPlayer: View {
|
|||
|
||||
SettingsStepperRow(
|
||||
icon: "goforward.plus",
|
||||
title: "Long press Skip",
|
||||
title: NSLocalizedString("Long press Skip", comment: ""),
|
||||
value: $skipIncrementHold,
|
||||
range: 5...300,
|
||||
step: 5,
|
||||
|
|
@ -308,19 +333,19 @@ struct SettingsViewPlayer: View {
|
|||
|
||||
SettingsToggleRow(
|
||||
icon: "hand.tap.fill",
|
||||
title: "Double Tap to Seek",
|
||||
title: NSLocalizedString("Double Tap to Seek", comment: ""),
|
||||
isOn: $doubleTapSeekEnabled
|
||||
)
|
||||
|
||||
SettingsToggleRow(
|
||||
icon: "forward.end",
|
||||
title: "Show Skip 85s Button",
|
||||
title: NSLocalizedString("Show Skip 85s Button", comment: ""),
|
||||
isOn: $skip85Visible
|
||||
)
|
||||
|
||||
SettingsToggleRow(
|
||||
icon: "forward.frame",
|
||||
title: "Show Skip Intro / Outro Buttons",
|
||||
title: NSLocalizedString("Show Skip Intro / Outro Buttons", comment: ""),
|
||||
isOn: $skipIntroOutroVisible,
|
||||
showDivider: false
|
||||
)
|
||||
|
|
@ -331,7 +356,7 @@ struct SettingsViewPlayer: View {
|
|||
.padding(.vertical, 20)
|
||||
}
|
||||
.scrollViewBottomPadding()
|
||||
.navigationTitle("Player")
|
||||
.navigationTitle(NSLocalizedString("Player", comment: ""))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -348,10 +373,10 @@ struct SubtitleSettingsSection: View {
|
|||
private let shadowOptions = [0, 1, 3, 6]
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Subtitle Settings") {
|
||||
SettingsSection(title: NSLocalizedString("Subtitle Settings", comment: "")) {
|
||||
SettingsToggleRow(
|
||||
icon: "captions.bubble",
|
||||
title: "Enable Subtitles",
|
||||
title: NSLocalizedString("Enable Subtitles", comment: ""),
|
||||
isOn: $subtitlesEnabled,
|
||||
showDivider: false
|
||||
)
|
||||
|
|
@ -363,7 +388,7 @@ struct SubtitleSettingsSection: View {
|
|||
|
||||
SettingsPickerRow(
|
||||
icon: "paintbrush",
|
||||
title: "Subtitle Color",
|
||||
title: NSLocalizedString("Subtitle Color", comment: ""),
|
||||
options: colors,
|
||||
optionToString: { $0.capitalized },
|
||||
selection: $foregroundColor
|
||||
|
|
@ -376,7 +401,7 @@ struct SubtitleSettingsSection: View {
|
|||
|
||||
SettingsPickerRow(
|
||||
icon: "shadow",
|
||||
title: "Shadow",
|
||||
title: NSLocalizedString("Shadow", comment: ""),
|
||||
options: shadowOptions,
|
||||
optionToString: { "\($0)" },
|
||||
selection: Binding(
|
||||
|
|
@ -392,7 +417,7 @@ struct SubtitleSettingsSection: View {
|
|||
|
||||
SettingsToggleRow(
|
||||
icon: "rectangle.fill",
|
||||
title: "Background Enabled",
|
||||
title: NSLocalizedString("Background Enabled", comment: ""),
|
||||
isOn: $backgroundEnabled
|
||||
)
|
||||
.onChange(of: backgroundEnabled) { newValue in
|
||||
|
|
@ -403,7 +428,7 @@ struct SubtitleSettingsSection: View {
|
|||
|
||||
SettingsStepperRow(
|
||||
icon: "textformat.size",
|
||||
title: "Font Size",
|
||||
title: NSLocalizedString("Font Size", comment: ""),
|
||||
value: $fontSize,
|
||||
range: 12...36,
|
||||
step: 1
|
||||
|
|
@ -416,7 +441,7 @@ struct SubtitleSettingsSection: View {
|
|||
|
||||
SettingsStepperRow(
|
||||
icon: "arrow.up.and.down",
|
||||
title: "Bottom Padding",
|
||||
title: NSLocalizedString("Bottom Padding", comment: ""),
|
||||
value: $bottomPadding,
|
||||
range: 0...50,
|
||||
step: 1,
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ struct SettingsViewTrackers: View {
|
|||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
SettingsSection(title: "AniList") {
|
||||
SettingsSection(title: NSLocalizedString("AniList", comment: "")) {
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
LazyImage(url: URL(string: "https://raw.githubusercontent.com/cranci1/Ryu/2f10226aa087154974a70c1ec78aa83a47daced9/Ryu/Assets.xcassets/Listing/Anilist.imageset/anilist.png")) { state in
|
||||
|
|
@ -137,32 +137,30 @@ struct SettingsViewTrackers: View {
|
|||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("AniList.co")
|
||||
Text(NSLocalizedString("AniList.co", comment: ""))
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Group {
|
||||
if isAnilistLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
.frame(height: 18)
|
||||
} else if isAnilistLoggedIn {
|
||||
HStack(spacing: 0) {
|
||||
Text("Logged in as ")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
Text(anilistUsername)
|
||||
.font(.footnote)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(profileColor)
|
||||
}
|
||||
if isAnilistLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
.frame(height: 18)
|
||||
} else {
|
||||
Text(anilistStatus)
|
||||
} else if isAnilistLoggedIn {
|
||||
HStack(spacing: 0) {
|
||||
Text(NSLocalizedString("Logged in as", comment: ""))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.frame(height: 18)
|
||||
Text(anilistUsername)
|
||||
.font(.footnote)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(profileColor)
|
||||
}
|
||||
.frame(height: 18)
|
||||
} else {
|
||||
Text(NSLocalizedString("You are not logged in", comment: ""))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.frame(height: 18)
|
||||
}
|
||||
}
|
||||
.frame(height: 60, alignment: .center)
|
||||
|
|
@ -179,7 +177,7 @@ struct SettingsViewTrackers: View {
|
|||
|
||||
SettingsToggleRow(
|
||||
icon: "arrow.triangle.2.circlepath",
|
||||
title: "Sync anime progress",
|
||||
title: NSLocalizedString("Sync anime progress", comment: ""),
|
||||
isOn: $isSendPushUpdates,
|
||||
showDivider: false
|
||||
)
|
||||
|
|
@ -200,7 +198,7 @@ struct SettingsViewTrackers: View {
|
|||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(isAnilistLoggedIn ? .red : .accentColor)
|
||||
|
||||
Text(isAnilistLoggedIn ? "Log Out from AniList" : "Log In with AniList")
|
||||
Text(isAnilistLoggedIn ? NSLocalizedString("Log Out from AniList", comment: "") : NSLocalizedString("Log In with AniList", comment: ""))
|
||||
.foregroundStyle(isAnilistLoggedIn ? .red : .accentColor)
|
||||
|
||||
Spacer()
|
||||
|
|
@ -212,7 +210,7 @@ struct SettingsViewTrackers: View {
|
|||
}
|
||||
}
|
||||
|
||||
SettingsSection(title: "Trakt") {
|
||||
SettingsSection(title: NSLocalizedString("Trakt", comment: "")) {
|
||||
VStack(spacing: 0) {
|
||||
HStack(alignment: .center, spacing: 10) {
|
||||
LazyImage(url: URL(string: "https://static-00.iconduck.com/assets.00/trakt-icon-2048x2048-2633ksxg.png")) { state in
|
||||
|
|
@ -232,32 +230,30 @@ struct SettingsViewTrackers: View {
|
|||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Trakt.tv")
|
||||
Text(NSLocalizedString("Trakt.tv", comment: ""))
|
||||
.font(.title3)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Group {
|
||||
if isTraktLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
.frame(height: 18)
|
||||
} else if isTraktLoggedIn {
|
||||
HStack(spacing: 0) {
|
||||
Text("Logged in as ")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
Text(traktUsername)
|
||||
.font(.footnote)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
if isTraktLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
.frame(height: 18)
|
||||
} else {
|
||||
Text(traktStatus)
|
||||
} else if isTraktLoggedIn {
|
||||
HStack(spacing: 0) {
|
||||
Text(NSLocalizedString("Logged in as", comment: ""))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.frame(height: 18)
|
||||
Text(traktUsername)
|
||||
.font(.footnote)
|
||||
.fontWeight(.medium)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
.frame(height: 18)
|
||||
} else {
|
||||
Text(NSLocalizedString("You are not logged in", comment: ""))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.frame(height: 18)
|
||||
}
|
||||
}
|
||||
.frame(height: 60, alignment: .center)
|
||||
|
|
@ -268,6 +264,18 @@ struct SettingsViewTrackers: View {
|
|||
.padding(.vertical, 12)
|
||||
.frame(height: 84)
|
||||
|
||||
if isTraktLoggedIn {
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
SettingsToggleRow(
|
||||
icon: "arrow.triangle.2.circlepath",
|
||||
title: NSLocalizedString("Sync TV shows progress", comment: ""),
|
||||
isOn: $isSendTraktUpdates,
|
||||
showDivider: false
|
||||
)
|
||||
}
|
||||
|
||||
Divider()
|
||||
.padding(.horizontal, 16)
|
||||
|
||||
|
|
@ -283,7 +291,7 @@ struct SettingsViewTrackers: View {
|
|||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(isTraktLoggedIn ? .red : .accentColor)
|
||||
|
||||
Text(isTraktLoggedIn ? "Log Out from Trakt" : "Log In with Trakt")
|
||||
Text(isTraktLoggedIn ? NSLocalizedString("Log Out from Trakt", comment: "") : NSLocalizedString("Log In with Trakt", comment: ""))
|
||||
.foregroundStyle(isTraktLoggedIn ? .red : .accentColor)
|
||||
|
||||
Spacer()
|
||||
|
|
@ -296,14 +304,14 @@ struct SettingsViewTrackers: View {
|
|||
}
|
||||
|
||||
SettingsSection(
|
||||
title: "Info",
|
||||
footer: "Sora and cranci1 are not affiliated with AniList nor Trakt in any way.\n\nAlso note that progresses update may not be 100% accurate."
|
||||
title: NSLocalizedString("Info", comment: ""),
|
||||
footer: NSLocalizedString("Sora and cranci1 are not affiliated with AniList or Trakt in any way.\n\nAlso note that progress updates may not be 100% accurate.", comment: "")
|
||||
) {}
|
||||
}
|
||||
.padding(.vertical, 20)
|
||||
}
|
||||
.scrollViewBottomPadding()
|
||||
.navigationTitle("Trackers")
|
||||
.navigationTitle(NSLocalizedString("Trackers", comment: ""))
|
||||
.onAppear {
|
||||
updateAniListStatus()
|
||||
updateTraktStatus()
|
||||
|
|
|
|||
|
|
@ -6,16 +6,17 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import NukeUI
|
||||
|
||||
fileprivate struct SettingsNavigationRow: View {
|
||||
let icon: String
|
||||
let title: String
|
||||
let titleKey: String
|
||||
let isExternal: Bool
|
||||
let textColor: Color
|
||||
|
||||
init(icon: String, title: String, isExternal: Bool = false, textColor: Color = .primary) {
|
||||
init(icon: String, titleKey: String, isExternal: Bool = false, textColor: Color = .primary) {
|
||||
self.icon = icon
|
||||
self.title = title
|
||||
self.titleKey = titleKey
|
||||
self.isExternal = isExternal
|
||||
self.textColor = textColor
|
||||
}
|
||||
|
|
@ -26,7 +27,7 @@ fileprivate struct SettingsNavigationRow: View {
|
|||
.frame(width: 24, height: 24)
|
||||
.foregroundStyle(textColor)
|
||||
|
||||
Text(title)
|
||||
Text(NSLocalizedString(titleKey, comment: ""))
|
||||
.foregroundStyle(textColor)
|
||||
|
||||
Spacer()
|
||||
|
|
@ -43,10 +44,93 @@ fileprivate struct SettingsNavigationRow: View {
|
|||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate struct ModulePreviewRow: View {
|
||||
@EnvironmentObject var moduleManager: ModuleManager
|
||||
@AppStorage("selectedModuleId") private var selectedModuleId: String?
|
||||
|
||||
private var selectedModule: ScrapingModule? {
|
||||
guard let id = selectedModuleId else { return nil }
|
||||
return moduleManager.modules.first { $0.id.uuidString == id }
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
if let module = selectedModule {
|
||||
LazyImage(url: URL(string: module.metadata.iconUrl)) { state in
|
||||
if let uiImage = state.imageContainer?.image {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 60, height: 60)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
} else {
|
||||
Image(systemName: "cube")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(module.metadata.sourceName)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("Tap to manage your modules")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.gray)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
} else {
|
||||
Image(systemName: "cube")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("No Module Selected")
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("Tap to select a module")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.gray)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.foregroundStyle(.gray)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 16)
|
||||
.background(.ultraThinMaterial)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.strokeBorder(
|
||||
LinearGradient(
|
||||
gradient: Gradient(stops: [
|
||||
.init(color: Color.accentColor.opacity(0.3), location: 0),
|
||||
.init(color: Color.accentColor.opacity(0), location: 1)
|
||||
]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
),
|
||||
lineWidth: 0.5
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsView: View {
|
||||
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "ALPHA"
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@StateObject var settings = Settings()
|
||||
@EnvironmentObject var moduleManager: ModuleManager
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
|
|
@ -59,35 +143,43 @@ struct SettingsView: View {
|
|||
.padding(.horizontal, 20)
|
||||
.padding(.top, 16)
|
||||
|
||||
// Modules Section at the top
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("MAIN")
|
||||
Text("MODULES")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
NavigationLink(destination: SettingsViewModule()) {
|
||||
ModulePreviewRow()
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("MAIN SETTINGS")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
NavigationLink(destination: SettingsViewGeneral()) {
|
||||
SettingsNavigationRow(icon: "gearshape", title: "General Preferences")
|
||||
SettingsNavigationRow(icon: "gearshape", titleKey: "General Preferences")
|
||||
}
|
||||
Divider().padding(.horizontal, 16)
|
||||
|
||||
NavigationLink(destination: SettingsViewPlayer()) {
|
||||
SettingsNavigationRow(icon: "play.circle", title: "Video Player")
|
||||
SettingsNavigationRow(icon: "play.circle", titleKey: "Video Player")
|
||||
}
|
||||
Divider().padding(.horizontal, 16)
|
||||
|
||||
NavigationLink(destination: SettingsViewDownloads()) {
|
||||
SettingsNavigationRow(icon: "arrow.down.circle", title: "Download")
|
||||
}
|
||||
Divider().padding(.horizontal, 16)
|
||||
|
||||
NavigationLink(destination: SettingsViewModule()) {
|
||||
SettingsNavigationRow(icon: "cube", title: "Modules")
|
||||
SettingsNavigationRow(icon: "arrow.down.circle", titleKey: "Download")
|
||||
}
|
||||
Divider().padding(.horizontal, 16)
|
||||
|
||||
NavigationLink(destination: SettingsViewTrackers()) {
|
||||
SettingsNavigationRow(icon: "square.stack.3d.up", title: "Trackers")
|
||||
SettingsNavigationRow(icon: "square.stack.3d.up", titleKey: "Trackers")
|
||||
}
|
||||
}
|
||||
.background(.ultraThinMaterial)
|
||||
|
|
@ -110,19 +202,19 @@ struct SettingsView: View {
|
|||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("DATA/LOGS")
|
||||
Text("DATA & LOGS")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
NavigationLink(destination: SettingsViewData()) {
|
||||
SettingsNavigationRow(icon: "folder", title: "Data")
|
||||
SettingsNavigationRow(icon: "folder", titleKey: "Data")
|
||||
}
|
||||
Divider().padding(.horizontal, 16)
|
||||
|
||||
NavigationLink(destination: SettingsViewLogger()) {
|
||||
SettingsNavigationRow(icon: "doc.text", title: "Logs")
|
||||
SettingsNavigationRow(icon: "doc.text", titleKey: "Logs")
|
||||
}
|
||||
}
|
||||
.background(.ultraThinMaterial)
|
||||
|
|
@ -145,21 +237,21 @@ struct SettingsView: View {
|
|||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("INFOS")
|
||||
Text(NSLocalizedString("INFOS", comment: ""))
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.padding(.horizontal, 20)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
NavigationLink(destination: SettingsViewAbout()) {
|
||||
SettingsNavigationRow(icon: "info.circle", title: "About Sora")
|
||||
SettingsNavigationRow(icon: "info.circle", titleKey: "About Sora")
|
||||
}
|
||||
Divider().padding(.horizontal, 16)
|
||||
|
||||
Link(destination: URL(string: "https://github.com/cranci1/Sora")!) {
|
||||
SettingsNavigationRow(
|
||||
icon: "chevron.left.forwardslash.chevron.right",
|
||||
title: "Sora GitHub Repository",
|
||||
titleKey: "Sora GitHub Repository",
|
||||
isExternal: true,
|
||||
textColor: .gray
|
||||
)
|
||||
|
|
@ -169,7 +261,7 @@ struct SettingsView: View {
|
|||
Link(destination: URL(string: "https://discord.gg/x7hppDWFDZ")!) {
|
||||
SettingsNavigationRow(
|
||||
icon: "bubble.left.and.bubble.right",
|
||||
title: "Join the Discord",
|
||||
titleKey: "Join the Discord",
|
||||
isExternal: true,
|
||||
textColor: .gray
|
||||
)
|
||||
|
|
@ -179,7 +271,7 @@ struct SettingsView: View {
|
|||
Link(destination: URL(string: "https://github.com/cranci1/Sora/issues")!) {
|
||||
SettingsNavigationRow(
|
||||
icon: "exclamationmark.circle",
|
||||
title: "Report an Issue",
|
||||
titleKey: "Report an Issue",
|
||||
isExternal: true,
|
||||
textColor: .gray
|
||||
)
|
||||
|
|
@ -189,7 +281,7 @@ struct SettingsView: View {
|
|||
Link(destination: URL(string: "https://github.com/cranci1/Sora/blob/dev/LICENSE")!) {
|
||||
SettingsNavigationRow(
|
||||
icon: "doc.text",
|
||||
title: "License (GPLv3.0)",
|
||||
titleKey: "License (GPLv3.0)",
|
||||
isExternal: true,
|
||||
textColor: .gray
|
||||
)
|
||||
|
|
@ -214,7 +306,7 @@ struct SettingsView: View {
|
|||
.padding(.horizontal, 20)
|
||||
}
|
||||
|
||||
Text("Running Sora \(version) - cranci1")
|
||||
Text("Sora \(version) by cranci1")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.gray)
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
|
|
@ -258,6 +350,12 @@ class Settings: ObservableObject {
|
|||
updateAppearance()
|
||||
}
|
||||
}
|
||||
@Published var selectedLanguage: String {
|
||||
didSet {
|
||||
UserDefaults.standard.set(selectedLanguage, forKey: "selectedLanguage")
|
||||
updateLanguage()
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
self.accentColor = .primary
|
||||
|
|
@ -267,7 +365,9 @@ class Settings: ObservableObject {
|
|||
} else {
|
||||
self.selectedAppearance = .system
|
||||
}
|
||||
self.selectedLanguage = UserDefaults.standard.string(forKey: "selectedLanguage") ?? "English"
|
||||
updateAppearance()
|
||||
updateLanguage()
|
||||
}
|
||||
|
||||
func updateAccentColor(currentColorScheme: ColorScheme? = nil) {
|
||||
|
|
@ -298,4 +398,10 @@ class Settings: ObservableObject {
|
|||
windowScene.windows.first?.overrideUserInterfaceStyle = .dark
|
||||
}
|
||||
}
|
||||
|
||||
func updateLanguage() {
|
||||
let languageCode = selectedLanguage == "Dutch" ? "nl" : "en"
|
||||
UserDefaults.standard.set([languageCode], forKey: "AppleLanguages")
|
||||
UserDefaults.standard.synchronize()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
42
Sora/Views/SplashScreenView.swift
Normal file
42
Sora/Views/SplashScreenView.swift
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
//
|
||||
// SplashScreenView.swift
|
||||
// Sora
|
||||
//
|
||||
// Created by paul on 11/06/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SplashScreenView: View {
|
||||
@State private var isAnimating = false
|
||||
@State private var showMainApp = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
if showMainApp {
|
||||
ContentView()
|
||||
} else {
|
||||
VStack {
|
||||
Image("SplashScreenIcon")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 200, height: 200)
|
||||
.cornerRadius(24)
|
||||
.scaleEffect(isAnimating ? 1.2 : 1.0)
|
||||
.opacity(isAnimating ? 1.0 : 0.0)
|
||||
}
|
||||
.onAppear {
|
||||
withAnimation(.easeIn(duration: 0.5)) {
|
||||
isAnimating = true
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) {
|
||||
withAnimation(.easeOut(duration: 0.5)) {
|
||||
showMainApp = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -18,10 +18,12 @@
|
|||
0457C59F2DE78267000AFBD9 /* BookmarkGridItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C5992DE78267000AFBD9 /* BookmarkGridItemView.swift */; };
|
||||
0457C5A12DE78385000AFBD9 /* BookmarksDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C5A02DE78385000AFBD9 /* BookmarksDetailView.swift */; };
|
||||
04CD76DB2DE20F2200733536 /* AllWatching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04CD76DA2DE20F2200733536 /* AllWatching.swift */; };
|
||||
04EAC39A2DF9E0DB00BBD483 /* SplashScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04EAC3992DF9E0DB00BBD483 /* SplashScreenView.swift */; };
|
||||
04F08EDC2DE10BF3006B29D9 /* ProgressiveBlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */; };
|
||||
04F08EDF2DE10C1D006B29D9 /* TabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EDE2DE10C1A006B29D9 /* TabBar.swift */; };
|
||||
04F08EE22DE10C40006B29D9 /* TabItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EE12DE10C27006B29D9 /* TabItem.swift */; };
|
||||
04F08EE42DE10D6F006B29D9 /* AllBookmarks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EE32DE10D6B006B29D9 /* AllBookmarks.swift */; };
|
||||
130326B62DF979AC00AEF610 /* WebAuthenticationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130326B52DF979AC00AEF610 /* WebAuthenticationManager.swift */; };
|
||||
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */; };
|
||||
13103E8B2D58E028000F0673 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8A2D58E028000F0673 /* View.swift */; };
|
||||
13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8D2D58E04A000F0673 /* SkeletonCell.swift */; };
|
||||
|
|
@ -57,6 +59,7 @@
|
|||
138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */; };
|
||||
138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */; };
|
||||
138FE1D02DECA00D00936D81 /* TMDB-FetchID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */; };
|
||||
138FF5642DFB17FF00083087 /* SoraCore in Frameworks */ = {isa = PBXBuildFile; productRef = 138FF5632DFB17FF00083087 /* SoraCore */; };
|
||||
1398FB3F2DE4E161004D3F5F /* SettingsViewAbout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1398FB3E2DE4E161004D3F5F /* SettingsViewAbout.swift */; };
|
||||
139935662D468C450065CEFF /* ModuleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139935652D468C450065CEFF /* ModuleManager.swift */; };
|
||||
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */; };
|
||||
|
|
@ -85,16 +88,16 @@
|
|||
1E47859B2DEBC5960095BF2F /* AnilistMatchPopupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */; };
|
||||
1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; };
|
||||
1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */; };
|
||||
1EDA48F42DFAC374002A4EC3 /* TMDBMatchPopupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EDA48F32DFAC374002A4EC3 /* TMDBMatchPopupView.swift */; };
|
||||
1EF5C3A92DB988E40032BF07 /* CommunityLib.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */; };
|
||||
7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */; };
|
||||
722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */; };
|
||||
722248632DCBAA4700CABE2D /* JSController-HeaderManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */; };
|
||||
722248642DCBAA4700CABE2D /* JSController+M3U8Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */; };
|
||||
722248662DCBC13E00CABE2D /* JSController-Downloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722248652DCBC13E00CABE2D /* JSController-Downloads.swift */; };
|
||||
72443C7D2DC8036500A61321 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72443C7C2DC8036500A61321 /* DownloadView.swift */; };
|
||||
72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */; };
|
||||
727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */; };
|
||||
727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = 727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */; };
|
||||
72D546DB2DFC5E950044C567 /* JSController+Downloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72D546DA2DFC5E950044C567 /* JSController+Downloader.swift */; };
|
||||
73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
|
|
@ -110,10 +113,12 @@
|
|||
0457C59B2DE78267000AFBD9 /* BookmarkLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkLink.swift; sourceTree = "<group>"; };
|
||||
0457C5A02DE78385000AFBD9 /* BookmarksDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksDetailView.swift; sourceTree = "<group>"; };
|
||||
04CD76DA2DE20F2200733536 /* AllWatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllWatching.swift; sourceTree = "<group>"; };
|
||||
04EAC3992DF9E0DB00BBD483 /* SplashScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreenView.swift; sourceTree = "<group>"; };
|
||||
04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressiveBlurView.swift; sourceTree = "<group>"; };
|
||||
04F08EDE2DE10C1A006B29D9 /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = "<group>"; };
|
||||
04F08EE12DE10C27006B29D9 /* TabItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItem.swift; sourceTree = "<group>"; };
|
||||
04F08EE32DE10D6B006B29D9 /* AllBookmarks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllBookmarks.swift; sourceTree = "<group>"; };
|
||||
130326B52DF979AC00AEF610 /* WebAuthenticationManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebAuthenticationManager.swift; sourceTree = "<group>"; };
|
||||
130C6BF82D53A4C200DC1432 /* Sora.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Sora.entitlements; sourceTree = "<group>"; };
|
||||
130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewData.swift; sourceTree = "<group>"; };
|
||||
13103E8A2D58E028000F0673 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -176,16 +181,16 @@
|
|||
1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnilistMatchPopupView.swift; sourceTree = "<group>"; };
|
||||
1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = "<group>"; };
|
||||
1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = "<group>"; };
|
||||
1EDA48F32DFAC374002A4EC3 /* TMDBMatchPopupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TMDBMatchPopupView.swift; sourceTree = "<group>"; };
|
||||
1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityLib.swift; sourceTree = "<group>"; };
|
||||
7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadModels.swift; sourceTree = "<group>"; };
|
||||
7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = M3U8StreamExtractor.swift; sourceTree = "<group>"; };
|
||||
722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-HeaderManager.swift"; sourceTree = "<group>"; };
|
||||
722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+M3U8Download.swift"; sourceTree = "<group>"; };
|
||||
722248652DCBC13E00CABE2D /* JSController-Downloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Downloads.swift"; sourceTree = "<group>"; };
|
||||
72443C7C2DC8036500A61321 /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = "<group>"; };
|
||||
72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewDownloads.swift; sourceTree = "<group>"; };
|
||||
7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-StreamTypeDownload.swift"; sourceTree = "<group>"; };
|
||||
727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+MP4Download.swift"; sourceTree = "<group>"; };
|
||||
72D546DA2DFC5E950044C567 /* JSController+Downloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+Downloader.swift"; sourceTree = "<group>"; };
|
||||
73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
|
|
@ -196,6 +201,7 @@
|
|||
files = (
|
||||
13367ECC2DF70698009CB33F /* Nuke in Frameworks */,
|
||||
13637B902DE0ECD200BDA2FC /* Drops in Frameworks */,
|
||||
138FF5642DFB17FF00083087 /* SoraCore in Frameworks */,
|
||||
13637B932DE0ECDB00BDA2FC /* MarqueeLabel in Frameworks */,
|
||||
13367ECE2DF70698009CB33F /* NukeUI in Frameworks */,
|
||||
);
|
||||
|
|
@ -260,6 +266,15 @@
|
|||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
130326B42DF979A300AEF610 /* WebAuthentication */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
130326B52DF979AC00AEF610 /* WebAuthenticationManager.swift */,
|
||||
);
|
||||
name = WebAuthentication;
|
||||
path = Sora/Utils/WebAuthentication;
|
||||
sourceTree = SOURCE_ROOT;
|
||||
};
|
||||
13103E802D589D6C000F0673 /* Tracking Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -340,6 +355,7 @@
|
|||
133D7C7B2D2BE2630075467E /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
04EAC3992DF9E0DB00BBD483 /* SplashScreenView.swift */,
|
||||
72443C7C2DC8036500A61321 /* DownloadView.swift */,
|
||||
0402DA122DE7B5EC003BB42C /* SearchView */,
|
||||
133D7C7F2D2BE2630075467E /* MediaInfoView */,
|
||||
|
|
@ -352,8 +368,8 @@
|
|||
133D7C7F2D2BE2630075467E /* MediaInfoView */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1E0435F02DFCB86800FF6808 /* CustomMatching */,
|
||||
138AA1B52D2D66EC0021F9DF /* EpisodeCell */,
|
||||
1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */,
|
||||
133D7C802D2BE2630075467E /* MediaInfoView.swift */,
|
||||
);
|
||||
path = MediaInfoView;
|
||||
|
|
@ -378,6 +394,7 @@
|
|||
133D7C852D2BE2640075467E /* Utils */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
130326B42DF979A300AEF610 /* WebAuthentication */,
|
||||
0457C5962DE7712A000AFBD9 /* ViewModifiers */,
|
||||
04F08EE02DE10C22006B29D9 /* Models */,
|
||||
04F08EDD2DE10C05006B29D9 /* TabBar */,
|
||||
|
|
@ -450,11 +467,10 @@
|
|||
134A387B2DE4B5B90041B687 /* Downloads */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
72D546DA2DFC5E950044C567 /* JSController+Downloader.swift */,
|
||||
7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */,
|
||||
727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */,
|
||||
722248652DCBC13E00CABE2D /* JSController-Downloads.swift */,
|
||||
722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */,
|
||||
722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */,
|
||||
);
|
||||
path = Downloads;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -592,6 +608,15 @@
|
|||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1E0435F02DFCB86800FF6808 /* CustomMatching */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
1EDA48F32DFAC374002A4EC3 /* TMDBMatchPopupView.swift */,
|
||||
1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */,
|
||||
);
|
||||
path = CustomMatching;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
72443C832DC8046500A61321 /* DownloadUtils */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -623,6 +648,7 @@
|
|||
13637B922DE0ECDB00BDA2FC /* MarqueeLabel */,
|
||||
13367ECB2DF70698009CB33F /* Nuke */,
|
||||
13367ECD2DF70698009CB33F /* NukeUI */,
|
||||
138FF5632DFB17FF00083087 /* SoraCore */,
|
||||
);
|
||||
productName = Sora;
|
||||
productReference = 133D7C6A2D2BE2500075467E /* Sulfur.app */;
|
||||
|
|
@ -649,12 +675,14 @@
|
|||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = 133D7C612D2BE2500075467E;
|
||||
packageReferences = (
|
||||
13637B8E2DE0ECD200BDA2FC /* XCRemoteSwiftPackageReference "Drops" */,
|
||||
13637B912DE0ECDB00BDA2FC /* XCRemoteSwiftPackageReference "MarqueeLabel" */,
|
||||
13367ECA2DF70698009CB33F /* XCRemoteSwiftPackageReference "Nuke" */,
|
||||
138FF5622DFB17FF00083087 /* XCRemoteSwiftPackageReference "SoraCore" */,
|
||||
);
|
||||
productRefGroup = 133D7C6B2D2BE2500075467E /* Products */;
|
||||
projectDirPath = "";
|
||||
|
|
@ -685,6 +713,7 @@
|
|||
files = (
|
||||
135CCBE22D4D1138008B9C0E /* SettingsViewPlayer.swift in Sources */,
|
||||
131270172DC13A010093AA9C /* DownloadManager.swift in Sources */,
|
||||
1EDA48F42DFAC374002A4EC3 /* TMDBMatchPopupView.swift in Sources */,
|
||||
1327FBA72D758CEA00FC6689 /* Analytics.swift in Sources */,
|
||||
13DC0C462D302C7500D0F966 /* VideoPlayer.swift in Sources */,
|
||||
1359ED142D76F49900C13034 /* finTopView.swift in Sources */,
|
||||
|
|
@ -699,6 +728,7 @@
|
|||
1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */,
|
||||
136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */,
|
||||
13DB46902D900A38008CBC03 /* URL.swift in Sources */,
|
||||
130326B62DF979AC00AEF610 /* WebAuthenticationManager.swift in Sources */,
|
||||
04F08EE42DE10D6F006B29D9 /* AllBookmarks.swift in Sources */,
|
||||
138FE1D02DECA00D00936D81 /* TMDB-FetchID.swift in Sources */,
|
||||
130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */,
|
||||
|
|
@ -717,6 +747,7 @@
|
|||
13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */,
|
||||
133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */,
|
||||
04F08EE22DE10C40006B29D9 /* TabItem.swift in Sources */,
|
||||
04EAC39A2DF9E0DB00BBD483 /* SplashScreenView.swift in Sources */,
|
||||
132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */,
|
||||
131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */,
|
||||
04CD76DB2DE20F2200733536 /* AllWatching.swift in Sources */,
|
||||
|
|
@ -731,7 +762,6 @@
|
|||
13DB468E2D90093A008CBC03 /* Anilist-Token.swift in Sources */,
|
||||
1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */,
|
||||
722248632DCBAA4700CABE2D /* JSController-HeaderManager.swift in Sources */,
|
||||
722248642DCBAA4700CABE2D /* JSController+M3U8Download.swift in Sources */,
|
||||
133D7C942D2BE2640075467E /* JSController.swift in Sources */,
|
||||
133D7C922D2BE2640075467E /* URLSession.swift in Sources */,
|
||||
0457C5A12DE78385000AFBD9 /* BookmarksDetailView.swift in Sources */,
|
||||
|
|
@ -750,9 +780,9 @@
|
|||
13B77E202DA457AA00126FDF /* AniListPushUpdates.swift in Sources */,
|
||||
13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */,
|
||||
727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */,
|
||||
727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */,
|
||||
0402DA132DE7B5EC003BB42C /* SearchStateView.swift in Sources */,
|
||||
0402DA142DE7B5EC003BB42C /* SearchResultsGrid.swift in Sources */,
|
||||
72D546DB2DFC5E950044C567 /* JSController+Downloader.swift in Sources */,
|
||||
0402DA152DE7B5EC003BB42C /* SearchComponents.swift in Sources */,
|
||||
1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */,
|
||||
04F08EDF2DE10C1D006B29D9 /* TabBar.swift in Sources */,
|
||||
|
|
@ -935,7 +965,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.3.0;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
@ -977,7 +1007,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.3.0;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
@ -1039,6 +1069,14 @@
|
|||
kind = branch;
|
||||
};
|
||||
};
|
||||
138FF5622DFB17FF00083087 /* XCRemoteSwiftPackageReference "SoraCore" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/cranci1/SoraCore";
|
||||
requirement = {
|
||||
branch = main;
|
||||
kind = branch;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
|
|
@ -1062,6 +1100,11 @@
|
|||
package = 13637B912DE0ECDB00BDA2FC /* XCRemoteSwiftPackageReference "MarqueeLabel" */;
|
||||
productName = MarqueeLabel;
|
||||
};
|
||||
138FF5632DFB17FF00083087 /* SoraCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 138FF5622DFB17FF00083087 /* XCRemoteSwiftPackageReference "SoraCore" */;
|
||||
productName = SoraCore;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 133D7C622D2BE2500075467E /* Project object */;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"originHash" : "07beed18a1a0b5e52eea618e423e9ca1c37c24c4d3d4ec31d68c1664db0f0596",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "drops",
|
||||
|
|
@ -26,7 +27,16 @@
|
|||
"branch" : "main",
|
||||
"revision" : "c7ba4833b1b38f09e9708858aeaf91babc69f65c"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "soracore",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/cranci1/SoraCore",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "957207dded41b1db9fbfdabde81ffb2e72e71b31"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
"version" : 3
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue