Merge branch 'dev'

This commit is contained in:
cranci1 2025-06-14 16:50:14 +02:00
commit 0f8acca4df
51 changed files with 7911 additions and 3643 deletions

View file

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View file

@ -117,6 +117,10 @@ struct ModuleSelectorMenu: View {
)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.systemGray6).opacity(0))
.cornerRadius(12)
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

@ -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 */;

View file

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