diff --git a/README.md b/README.md
index b2eb745..f91b15c 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
# Sora
-> Also known as Sulfur, for copyright issues.
+> Also known as Sulfur due to copyright considerations.
@@ -7,13 +7,14 @@
[](https://github.com/cranci1/Sora/actions/workflows/build.yml) [](https://discord.gg/XR3SrmUbpd) [](https://img.shields.io/badge/Platform-iOS%20%7C%20iPadOS%2015.0%2B%20%26%20macOS%2012.0%2B-red?logo=apple&logoColor=white)
-An iOS and macOS modular web scraping app, under the GPLv3.0 License.
+**An iOS and macOS modular web scraping app, under the GPLv3.0 License.**
## Table of Contents
- [Features](#features)
+- [Installation](#installation)
- [Frequently Asked Questions](#frequently-asked-questions)
- [Acknowledgements](#acknowledgements)
- [License](#license)
@@ -22,14 +23,29 @@ An iOS and macOS modular web scraping app, under the GPLv3.0 License.
## Features
- [x] iOS/iPadOS 15.0+ support
-- [x] macOS support 12.0+
-- [x] JavaScript module support
+- [x] macOS 12.0+ support
+- [x] JavaScript Main Core
+- [ ] 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 Media players (VLC, infuse, Outplayer, nPlayer)
+- [x] External media player support (VLC, Infuse, Outplayer, nPlayer, SenPlayer, IINA)
- [x] Background playback and Picture-in-Picture (PiP) support
+## Installation
+
+You can download Sora on the App Store for stable updates or on Testflight for more updates but maybe some instability. (Testflight is recommended):
+
+
+
+
+
+
+
+
+
+Additionally, you can install the app using Xcode or using the .ipa file, which you can find in the [Releases](https://github.com/cranci1/Sora/releases) tab or the [nightly](https://nightly.link/cranci1/Sora/workflows/build/dev/Sulfur-IPA.zip) build page.
+
## Frequently Asked Questions
1. **What is Sora?**
@@ -41,14 +57,17 @@ Yes, Sora is open-source and prioritizes user privacy. It does not store user da
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 module?**
-Sora does not include any modules by default. You will need to find and add the necessary modules yourself, or make them.
+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:
- [KingFisher](https://github.com/onevcat/Kingfisher) - MIT License
- [Drops](https://github.com/omaralbeik/Drops) - MIT License
+- [MarqueeLabel](https://github.com/cbpowell/MarqueeLabel) - MIT License
+Misc:
- [50/50](https://github.com/50n50) for the app icon
## License
@@ -86,4 +105,4 @@ This software is not affiliated with or endorsed by any third-party entity. Any
### DMCA
-The developer(s) is 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 send 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 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.
diff --git a/Sora/Info.plist b/Sora/Info.plist
index 6e00c4a..269b520 100644
--- a/Sora/Info.plist
+++ b/Sora/Info.plist
@@ -21,6 +21,7 @@
LSApplicationQueriesSchemes
+ iina
outplayer
infuse
vlc
diff --git a/Sora/Utils/ContinueWatching/ContinueWatchingItem.swift b/Sora/Utils/ContinueWatching/ContinueWatchingItem.swift
index 7490295..7154e4b 100644
--- a/Sora/Utils/ContinueWatching/ContinueWatchingItem.swift
+++ b/Sora/Utils/ContinueWatching/ContinueWatchingItem.swift
@@ -18,4 +18,5 @@ struct ContinueWatchingItem: Codable, Identifiable {
let subtitles: String?
let aniListID: Int?
let module: ScrapingModule
+ let headers: [String:String]?
}
diff --git a/Sora/Utils/ContinueWatching/DownloadManager.swift b/Sora/Utils/DownloadManager/DownloadManager.swift
similarity index 100%
rename from Sora/Utils/ContinueWatching/DownloadManager.swift
rename to Sora/Utils/DownloadManager/DownloadManager.swift
diff --git a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift
index 18f4d39..b5ddc34 100644
--- a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift
+++ b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift
@@ -162,7 +162,7 @@ extension JSContext {
if(method != "GET")
{
// Ensure body is properly serialized
- processedBody = body ? JSON.stringify(body) : null
+ processedBody = (body && (typeof body === 'object')) ? JSON.stringify(body) : (body || null)
}
return new Promise(function(resolve, reject) {
diff --git a/Sora/Utils/JSLoader/JSController-Streams.swift b/Sora/Utils/JSLoader/JSController-Streams.swift
index 85615de..2f29a05 100644
--- a/Sora/Utils/JSLoader/JSController-Streams.swift
+++ b/Sora/Utils/JSLoader/JSController-Streams.swift
@@ -9,9 +9,9 @@ import JavaScriptCore
extension JSController {
- func fetchStreamUrl(episodeUrl: String, softsub: Bool = false, module: ScrapingModule, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) {
+ func fetchStreamUrl(episodeUrl: String, softsub: Bool = false, module: ScrapingModule, completion: @escaping ((streams: [String]?, subtitles: [String]?, sources: [[String:Any]]? )) -> Void) {
guard let url = URL(string: episodeUrl) else {
- completion((nil, nil))
+ completion((nil, nil,nil))
return
}
@@ -20,13 +20,13 @@ extension JSController {
if let error = error {
Logger.shared.log("Network error: \(error)", type: "Error")
- DispatchQueue.main.async { completion((nil, nil)) }
+ DispatchQueue.main.async { completion((nil, nil,nil)) }
return
}
guard let data = data, let html = String(data: data, encoding: .utf8) else {
Logger.shared.log("Failed to decode HTML", type: "Error")
- DispatchQueue.main.async { completion((nil, nil)) }
+ DispatchQueue.main.async { completion((nil, nil, nil)) }
return
}
@@ -36,10 +36,21 @@ extension JSController {
if let data = resultString.data(using: .utf8) {
do {
if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
+ print("JSON DATA IS \(json) 2")
var streamUrls: [String]? = nil
var subtitleUrls: [String]? = nil
-
- if let streamsArray = json["streams"] as? [String] {
+ var streamUrlsAndHeaders : [[String:Any]]? = nil
+ if let streamSources = json["streams"] as? [[String:Any]]
+ {
+ streamUrlsAndHeaders = streamSources
+ Logger.shared.log("Found \(streamSources.count) streams and headers", type: "Stream")
+ }
+ else if let streamSource = json["stream"] as? [String:Any]
+ {
+ streamUrlsAndHeaders = [streamSource]
+ Logger.shared.log("Found single stream with headers", type: "Stream")
+ }
+ else if let streamsArray = json["streams"] as? [String] {
streamUrls = streamsArray
Logger.shared.log("Found \(streamsArray.count) streams", type: "Stream")
} else if let streamUrl = json["stream"] as? String {
@@ -57,45 +68,45 @@ extension JSController {
Logger.shared.log("Starting stream with \(streamUrls?.count ?? 0) sources and \(subtitleUrls?.count ?? 0) subtitles", type: "Stream")
DispatchQueue.main.async {
- completion((streamUrls, subtitleUrls))
+ completion((streamUrls, subtitleUrls,streamUrlsAndHeaders))
}
return
}
if let streamsArray = try? JSONSerialization.jsonObject(with: data, options: []) as? [String] {
Logger.shared.log("Starting multi-stream with \(streamsArray.count) sources", type: "Stream")
- DispatchQueue.main.async { completion((streamsArray, nil)) }
+ DispatchQueue.main.async { completion((streamsArray, nil,nil)) }
return
}
}
}
Logger.shared.log("Starting stream from: \(resultString)", type: "Stream")
- DispatchQueue.main.async { completion(([resultString], nil)) }
+ DispatchQueue.main.async { completion(([resultString], nil,nil)) }
} else {
Logger.shared.log("Failed to extract stream URL", type: "Error")
- DispatchQueue.main.async { completion((nil, nil)) }
+ DispatchQueue.main.async { completion((nil, nil,nil)) }
}
}.resume()
}
- func fetchStreamUrlJS(episodeUrl: String, softsub: Bool = false, module: ScrapingModule, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) {
+ func fetchStreamUrlJS(episodeUrl: String, softsub: Bool = false, module: ScrapingModule, completion: @escaping ((streams: [String]?, subtitles: [String]?,sources: [[String:Any]]? )) -> Void) {
if let exception = context.exception {
Logger.shared.log("JavaScript exception: \(exception)", type: "Error")
- completion((nil, nil))
+ completion((nil, nil,nil))
return
}
guard let extractStreamUrlFunction = context.objectForKeyedSubscript("extractStreamUrl") else {
Logger.shared.log("No JavaScript function extractStreamUrl found", type: "Error")
- completion((nil, nil))
+ completion((nil, nil,nil))
return
}
let promiseValue = extractStreamUrlFunction.call(withArguments: [episodeUrl])
guard let promise = promiseValue else {
Logger.shared.log("extractStreamUrl did not return a Promise", type: "Error")
- completion((nil, nil))
+ completion((nil, nil,nil))
return
}
@@ -106,10 +117,21 @@ extension JSController {
let data = jsonString.data(using: .utf8) {
do {
if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
+ print("JSON object is \(json) 1")
var streamUrls: [String]? = nil
var subtitleUrls: [String]? = nil
-
- if let streamsArray = json["streams"] as? [String] {
+ var streamUrlsAndHeaders : [[String:Any]]? = nil
+ if let streamSources = json["streams"] as? [[String:Any]]
+ {
+ streamUrlsAndHeaders = streamSources
+ Logger.shared.log("Found \(streamSources.count) streams and headers", type: "Stream")
+ }
+ else if let streamSource = json["stream"] as? [String:Any]
+ {
+ streamUrlsAndHeaders = [streamSource]
+ Logger.shared.log("Found single stream with headers", type: "Stream")
+ }
+ else if let streamsArray = json["streams"] as? [String] {
streamUrls = streamsArray
Logger.shared.log("Found \(streamsArray.count) streams", type: "Stream")
} else if let streamUrl = json["stream"] as? String {
@@ -127,14 +149,14 @@ extension JSController {
Logger.shared.log("Starting stream with \(streamUrls?.count ?? 0) sources and \(subtitleUrls?.count ?? 0) subtitles", type: "Stream")
DispatchQueue.main.async {
- completion((streamUrls, subtitleUrls))
+ completion((streamUrls, subtitleUrls,streamUrlsAndHeaders))
}
return
}
if let streamsArray = try? JSONSerialization.jsonObject(with: data, options: []) as? [String] {
Logger.shared.log("Starting multi-stream with \(streamsArray.count) sources", type: "Stream")
- DispatchQueue.main.async { completion((streamsArray, nil)) }
+ DispatchQueue.main.async { completion((streamsArray, nil,nil)) }
return
}
}
@@ -143,14 +165,14 @@ extension JSController {
let streamUrl = result.toString()
Logger.shared.log("Starting stream from: \(streamUrl ?? "nil")", type: "Stream")
DispatchQueue.main.async {
- completion((streamUrl != nil ? [streamUrl!] : nil, nil))
+ completion((streamUrl != nil ? [streamUrl!] : nil, nil,nil))
}
}
let catchBlock: @convention(block) (JSValue) -> Void = { error in
Logger.shared.log("Promise rejected: \(String(describing: error.toString()))", type: "Error")
DispatchQueue.main.async {
- completion((nil, nil))
+ completion((nil, nil,nil))
}
}
@@ -161,40 +183,40 @@ extension JSController {
promise.invokeMethod("catch", withArguments: [catchFunction as Any])
}
- func fetchStreamUrlJSSecond(episodeUrl: String, softsub: Bool = false, module: ScrapingModule, completion: @escaping ((streams: [String]?, subtitles: [String]?)) -> Void) {
+ func fetchStreamUrlJSSecond(episodeUrl: String, softsub: Bool = false, module: ScrapingModule, completion: @escaping ((streams: [String]?, subtitles: [String]?,sources: [[String:Any]]? )) -> Void) {
let url = URL(string: episodeUrl)!
let task = URLSession.custom.dataTask(with: url) { [weak self] data, response, error in
guard let self = self else { return }
if let error = error {
Logger.shared.log("URLSession error: \(error.localizedDescription)", type: "Error")
- DispatchQueue.main.async { completion((nil, nil)) }
+ DispatchQueue.main.async { completion((nil, nil,nil)) }
return
}
guard let data = data, let htmlString = String(data: data, encoding: .utf8) else {
Logger.shared.log("Failed to fetch HTML data", type: "Error")
- DispatchQueue.main.async { completion((nil, nil)) }
+ DispatchQueue.main.async { completion((nil, nil, nil)) }
return
}
DispatchQueue.main.async {
if let exception = self.context.exception {
Logger.shared.log("JavaScript exception: \(exception)", type: "Error")
- completion((nil, nil))
+ completion((nil, nil, nil))
return
}
guard let extractStreamUrlFunction = self.context.objectForKeyedSubscript("extractStreamUrl") else {
Logger.shared.log("No JavaScript function extractStreamUrl found", type: "Error")
- completion((nil, nil))
+ completion((nil, nil, nil))
return
}
let promiseValue = extractStreamUrlFunction.call(withArguments: [htmlString])
guard let promise = promiseValue else {
Logger.shared.log("extractStreamUrl did not return a Promise", type: "Error")
- completion((nil, nil))
+ completion((nil, nil, nil))
return
}
@@ -205,10 +227,21 @@ extension JSController {
let data = jsonString.data(using: .utf8) {
do {
if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
+ print("JSON object is \(json) 3 ")
var streamUrls: [String]? = nil
var subtitleUrls: [String]? = nil
-
- if let streamsArray = json["streams"] as? [String] {
+ var streamUrlsAndHeaders : [[String:Any]]? = nil
+ if let streamSources = json["streams"] as? [[String:Any]]
+ {
+ streamUrlsAndHeaders = streamSources
+ Logger.shared.log("Found \(streamSources.count) streams and headers", type: "Stream")
+ }
+ else if let streamSource = json["stream"] as? [String:Any]
+ {
+ streamUrlsAndHeaders = [streamSource]
+ Logger.shared.log("Found single stream with headers", type: "Stream")
+ }
+ else if let streamsArray = json["streams"] as? [String] {
streamUrls = streamsArray
Logger.shared.log("Found \(streamsArray.count) streams", type: "Stream")
} else if let streamUrl = json["stream"] as? String {
@@ -226,14 +259,14 @@ extension JSController {
Logger.shared.log("Starting stream with \(streamUrls?.count ?? 0) sources and \(subtitleUrls?.count ?? 0) subtitles", type: "Stream")
DispatchQueue.main.async {
- completion((streamUrls, subtitleUrls))
+ completion((streamUrls, subtitleUrls, streamUrlsAndHeaders))
}
return
}
if let streamsArray = try? JSONSerialization.jsonObject(with: data, options: []) as? [String] {
Logger.shared.log("Starting multi-stream with \(streamsArray.count) sources", type: "Stream")
- DispatchQueue.main.async { completion((streamsArray, nil)) }
+ DispatchQueue.main.async { completion((streamsArray, nil, nil)) }
return
}
}
@@ -242,14 +275,14 @@ extension JSController {
let streamUrl = result.toString()
Logger.shared.log("Starting stream from: \(streamUrl ?? "nil")", type: "Stream")
DispatchQueue.main.async {
- completion((streamUrl != nil ? [streamUrl!] : nil, nil))
+ completion((streamUrl != nil ? [streamUrl!] : nil, nil, nil))
}
}
let catchBlock: @convention(block) (JSValue) -> Void = { error in
Logger.shared.log("Promise rejected: \(String(describing: error.toString()))", type: "Error")
DispatchQueue.main.async {
- completion((nil, nil))
+ completion((nil, nil, nil))
}
}
diff --git a/Sora/Utils/Logger/Logger.swift b/Sora/Utils/Logger/Logger.swift
index 70ad048..28ce20a 100644
--- a/Sora/Utils/Logger/Logger.swift
+++ b/Sora/Utils/Logger/Logger.swift
@@ -16,6 +16,7 @@ class Logger {
let timestamp: Date
}
+ private let queue = DispatchQueue(label: "me.cranci.sora.logger", attributes: .concurrent)
private var logs: [LogEntry] = []
private let logFileURL: URL
private let logFilterViewModel = LogFilterViewModel.shared
@@ -29,23 +30,30 @@ class Logger {
guard logFilterViewModel.isFilterEnabled(for: type) else { return }
let entry = LogEntry(message: message, type: type, timestamp: Date())
- logs.append(entry)
- saveLogToFile(entry)
- debugLog(entry)
+ queue.async(flags: .barrier) {
+ self.logs.append(entry)
+ self.saveLogToFile(entry)
+ self.debugLog(entry)
+ }
}
-
func getLogs() -> String {
- let dateFormatter = DateFormatter()
- dateFormatter.dateFormat = "dd-MM HH:mm:ss"
- return logs.map { "[\(dateFormatter.string(from: $0.timestamp))] [\($0.type)] \($0.message)" }
+ var result = ""
+ queue.sync {
+ let dateFormatter = DateFormatter()
+ dateFormatter.dateFormat = "dd-MM HH:mm:ss"
+ result = logs.map { "[\(dateFormatter.string(from: $0.timestamp))] [\($0.type)] \($0.message)" }
.joined(separator: "\n----\n")
+ }
+ return result
}
func clearLogs() {
- logs.removeAll()
- try? FileManager.default.removeItem(at: logFileURL)
+ queue.async(flags: .barrier) {
+ self.logs.removeAll()
+ try? FileManager.default.removeItem(at: self.logFileURL)
+ }
}
private func saveLogToFile(_ log: LogEntry) {
@@ -69,10 +77,11 @@ class Logger {
/// Prints log messages to the Xcode console only in DEBUG mode
private func debugLog(_ entry: LogEntry) {
- #if DEBUG
+#if DEBUG
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "dd-MM HH:mm:ss"
let formattedMessage = "[\(dateFormatter.string(from: entry.timestamp))] [\(entry.type)] \(entry.message)"
print(formattedMessage)
- #endif
- }}
+#endif
+ }
+}
diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift
index 28c10da..9709dba 100644
--- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift
+++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift
@@ -22,6 +22,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
let subtitlesURL: String?
let onWatchNext: () -> Void
let aniListID: Int
+ var headers: [String:String]? = nil
private var aniListUpdatedSuccessfully = false
private var aniListUpdateImpossible: Bool = false
@@ -168,6 +169,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
private var volumeViewModel = VolumeViewModel()
var volumeSliderHostingView: UIView?
private var subtitleDelay: Double = 0.0
+ var currentPlaybackSpeed: Float = 1.0
init(module: ScrapingModule,
urlString: String,
@@ -177,7 +179,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
onWatchNext: @escaping () -> Void,
subtitlesURL: String?,
aniListID: Int,
- episodeImageUrl: String) {
+ episodeImageUrl: String,headers:[String:String]?) {
self.module = module
self.streamURL = urlString
@@ -188,6 +190,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
self.onWatchNext = onWatchNext
self.subtitlesURL = subtitlesURL
self.aniListID = aniListID
+ self.headers = headers
super.init(nibName: nil, bundle: nil)
@@ -196,8 +199,18 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}
var request = URLRequest(url: url)
- request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
- request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin")
+ if let mydict = headers, !mydict.isEmpty
+ {
+ for (key,value) in mydict
+ {
+ request.addValue(value, forHTTPHeaderField: key)
+ }
+ }
+ else
+ {
+ request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
+ request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin")
+ }
request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
forHTTPHeaderField: "User-Agent")
@@ -939,26 +952,36 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
sliderViewModel.outroSegments.removeAll()
if let op = skipIntervals.op {
- let start = max(0, op.start.seconds / duration)
- let end = min(1, op.end.seconds / duration)
- sliderViewModel.introSegments.append(start...end)
+ let start = max(0, op.start.seconds / max(duration, 0.01))
+ let end = min(1, op.end.seconds / max(duration, 0.01))
+
+ if start <= end {
+ sliderViewModel.introSegments.append(start...end)
+ }
}
if let ed = skipIntervals.ed {
- let start = max(0, ed.start.seconds / duration)
- let end = min(1, ed.end.seconds / duration)
- sliderViewModel.outroSegments.append(start...end)
+ let start = max(0, ed.start.seconds / max(duration, 0.01))
+ let end = min(1, ed.end.seconds / max(duration, 0.01))
+
+ if start <= end {
+ sliderViewModel.outroSegments.append(start...end)
+ }
}
let segmentsColor = self.getSegmentsColor()
- DispatchQueue.main.async {
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self else { return }
+
+ let validDuration = max(self.duration, 0.01)
+
self.sliderHostingController?.rootView = MusicProgressSlider(
value: Binding(
- get: { max(0, min(self.sliderViewModel.sliderValue, self.duration)) }, // Remove extra ')'
- set: { self.sliderViewModel.sliderValue = max(0, min($0, self.duration)) } // Remove extra ')'
+ get: { max(0, min(self.sliderViewModel.sliderValue, validDuration)) },
+ set: { self.sliderViewModel.sliderValue = max(0, min($0, validDuration)) }
),
- inRange: 0...(self.duration > 0 ? self.duration : 1.0),
+ inRange: 0...validDuration,
activeFillColor: .white,
fillColor: .white.opacity(0.6),
textColor: .white.opacity(0.7),
@@ -967,22 +990,13 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
onEditingChanged: { editing in
if editing {
self.isSliderEditing = true
-
self.wasPlayingBeforeSeek = (self.player.timeControlStatus == .playing)
self.originalRate = self.player.rate
-
self.player.pause()
} else {
-
- let target = CMTime(seconds: self.sliderViewModel.sliderValue,
- preferredTimescale: 600)
- self.player.seek(
- to: target,
- toleranceBefore: .zero,
- toleranceAfter: .zero
- ) { [weak self] _ in
+ let target = CMTime(seconds: self.sliderViewModel.sliderValue, preferredTimescale: 600)
+ self.player.seek(to: target, toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
guard let self = self else { return }
-
let final = self.player.currentTime().seconds
self.sliderViewModel.sliderValue = final
self.currentTimeVal = final
@@ -1380,7 +1394,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
fullUrl: self.fullUrl,
subtitles: self.subtitlesURL,
aniListID: self.aniListID,
- module: self.module
+ module: self.module,
+ headers: self.headers
)
ContinueWatchingManager.shared.save(item: item)
}
@@ -1473,9 +1488,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
currentMenuButtonTrailing.isActive = false
let anchor: NSLayoutXAxisAnchor
- if !qualityButton.isHidden {
+ if (!qualityButton.isHidden) {
anchor = qualityButton.leadingAnchor
- } else if !speedButton.isHidden {
+ } else if (!speedButton.isHidden) {
anchor = speedButton.leadingAnchor
} else {
anchor = controlsContainerView.trailingAnchor
@@ -1572,6 +1587,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
@objc func togglePlayPause() {
if isPlaying {
+ currentPlaybackSpeed = player.rate
player.pause()
isPlaying = false
playPauseButton.image = UIImage(systemName: "play.fill")
@@ -1588,6 +1604,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
}
} else {
player.play()
+ player.rate = currentPlaybackSpeed
isPlaying = true
playPauseButton.image = UIImage(systemName: "pause.fill")
}
@@ -1712,8 +1729,18 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
private func parseM3U8(url: URL, completion: @escaping () -> Void) {
var request = URLRequest(url: url)
- request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
- request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin")
+ if let mydict = headers, !mydict.isEmpty
+ {
+ for (key,value) in mydict
+ {
+ request.addValue(value, forHTTPHeaderField: key)
+ }
+ }
+ else
+ {
+ request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
+ request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin")
+ }
request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
forHTTPHeaderField: "User-Agent")
@@ -1799,8 +1826,18 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
let wasPlaying = player.rate > 0
var request = URLRequest(url: url)
- request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
- request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin")
+ if let mydict = headers, !mydict.isEmpty
+ {
+ for (key,value) in mydict
+ {
+ request.addValue(value, forHTTPHeaderField: key)
+ }
+ }
+ else
+ {
+ request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
+ request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin")
+ }
request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36",
forHTTPHeaderField: "User-Agent")
diff --git a/Sora/Utils/MediaPlayer/VideoPlayer.swift b/Sora/Utils/MediaPlayer/VideoPlayer.swift
index 122c466..b8bbb20 100644
--- a/Sora/Utils/MediaPlayer/VideoPlayer.swift
+++ b/Sora/Utils/MediaPlayer/VideoPlayer.swift
@@ -18,6 +18,7 @@ class VideoPlayerViewController: UIViewController {
var fullUrl: String = ""
var subtitles: String = ""
var aniListID: Int = 0
+ var headers: [String:String]? = nil
var episodeNumber: Int = 0
var episodeImageUrl: String = ""
@@ -40,8 +41,18 @@ class VideoPlayerViewController: UIViewController {
}
var request = URLRequest(url: url)
- request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
- request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin")
+ if let mydict = headers, !mydict.isEmpty
+ {
+ for (key,value) in mydict
+ {
+ request.addValue(value, forHTTPHeaderField: key)
+ }
+ }
+ else
+ {
+ request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
+ request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin")
+ }
request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent")
let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]])
@@ -127,7 +138,8 @@ class VideoPlayerViewController: UIViewController {
fullUrl: self.fullUrl,
subtitles: self.subtitles,
aniListID: self.aniListID,
- module: self.module
+ module: self.module,
+ headers: self.headers
)
ContinueWatchingManager.shared.save(item: item)
}
diff --git a/Sora/Views/LibraryView/LibraryView.swift b/Sora/Views/LibraryView/LibraryView.swift
index 0f7a233..94ea459 100644
--- a/Sora/Views/LibraryView/LibraryView.swift
+++ b/Sora/Views/LibraryView/LibraryView.swift
@@ -272,7 +272,9 @@ struct ContinueWatchingCell: View {
onWatchNext: { },
subtitlesURL: item.subtitles,
aniListID: item.aniListID ?? 0,
- episodeImageUrl: item.imageUrl
+ episodeImageUrl: item.imageUrl,
+ headers: item.headers ?? nil
+
)
customMediaPlayer.modalPresentationStyle = .fullScreen
diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift
index 6dec97e..9d4b525 100644
--- a/Sora/Views/MediaInfoView/MediaInfoView.swift
+++ b/Sora/Views/MediaInfoView/MediaInfoView.swift
@@ -59,293 +59,243 @@ struct MediaInfoView: View {
@Environment(\.dismiss) private var dismiss
@State private var orientationChanged: Bool = false
+ @State private var showLoadingAlert: Bool = false
private var isGroupedBySeasons: Bool {
return groupedEpisodes().count > 1
}
var body: some View {
- ZStack {
- Group {
- if isLoading {
- ProgressView()
- .padding()
- } else {
- ScrollView {
- VStack(alignment: .leading, spacing: 16) {
- HStack(alignment: .top, spacing: 10) {
- KFImage(URL(string: imageUrl))
- .placeholder {
- RoundedRectangle(cornerRadius: 10)
- .fill(Color.gray.opacity(0.3))
- .frame(width: 150, height: 225)
- .shimmering()
+ Group {
+ if isLoading {
+ ProgressView()
+ .padding()
+ } else {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ HStack(alignment: .top, spacing: 10) {
+ KFImage(URL(string: imageUrl))
+ .placeholder {
+ RoundedRectangle(cornerRadius: 10)
+ .fill(Color.gray.opacity(0.3))
+ .frame(width: 150, height: 225)
+ .shimmering()
+ }
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(width: 150, height: 225)
+ .clipped()
+ .cornerRadius(10)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text(title)
+ .font(.system(size: 17))
+ .fontWeight(.bold)
+ .onLongPressGesture {
+ UIPasteboard.general.string = title
+ DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill"))
}
- .resizable()
- .aspectRatio(contentMode: .fill)
- .frame(width: 150, height: 225)
- .clipped()
- .cornerRadius(10)
- VStack(alignment: .leading, spacing: 4) {
- Text(title)
- .font(.system(size: 17))
- .fontWeight(.bold)
- .onLongPressGesture {
- UIPasteboard.general.string = title
- DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill"))
- }
-
- if !aliases.isEmpty && aliases != title && aliases != "N/A" && aliases != "No Data" {
- Text(aliases)
- .font(.system(size: 13))
- .foregroundColor(.secondary)
- }
-
- Spacer()
-
- if !airdate.isEmpty && airdate != "N/A" && airdate != "No Data" {
- HStack(alignment: .center, spacing: 12) {
- HStack(spacing: 4) {
- Image(systemName: "calendar")
- .resizable()
- .frame(width: 15, height: 15)
- .foregroundColor(.secondary)
-
- Text(airdate)
- .font(.system(size: 12))
- .foregroundColor(.secondary)
- }
- .padding(4)
- }
- }
-
+ if !aliases.isEmpty && aliases != title && aliases != "N/A" && aliases != "No Data" {
+ Text(aliases)
+ .font(.system(size: 13))
+ .foregroundColor(.secondary)
+ }
+
+ Spacer()
+
+ if !airdate.isEmpty && airdate != "N/A" && airdate != "No Data" {
HStack(alignment: .center, spacing: 12) {
- Button(action: {
- openSafariViewController(with: href)
- }) {
- HStack(spacing: 4) {
- Text(module.metadata.sourceName)
- .font(.system(size: 13))
- .foregroundColor(.primary)
-
- Image(systemName: "safari")
- .resizable()
- .frame(width: 20, height: 20)
- .foregroundColor(.primary)
- }
- .padding(4)
- .background(Capsule().fill(Color.accentColor.opacity(0.4)))
+ HStack(spacing: 4) {
+ Image(systemName: "calendar")
+ .resizable()
+ .frame(width: 15, height: 15)
+ .foregroundColor(.secondary)
+
+ Text(airdate)
+ .font(.system(size: 12))
+ .foregroundColor(.secondary)
}
-
- Menu {
- Button(action: {
- showCustomIDAlert()
- }) {
- Label("Set Custom AniList ID", systemImage: "number")
- }
+ .padding(4)
+ }
+ }
+
+ HStack(alignment: .center, spacing: 12) {
+ Button(action: {
+ openSafariViewController(with: href)
+ }) {
+ HStack(spacing: 4) {
+ Text(module.metadata.sourceName)
+ .font(.system(size: 13))
+ .foregroundColor(.primary)
- if let customID = customAniListID {
- Button(action: {
- customAniListID = nil
- itemID = nil
- fetchItemID(byTitle: cleanTitle(title)) { result in
- switch result {
- case .success(let id):
- itemID = id
- case .failure(let error):
- Logger.shared.log("Failed to fetch AniList ID: \(error)")
- }
- }
- }) {
- Label("Reset AniList ID", systemImage: "arrow.clockwise")
- }
- }
-
- if let id = itemID ?? customAniListID {
- Button(action: {
- if let url = URL(string: "https://anilist.co/anime/\(id)") {
- openSafariViewController(with: url.absoluteString)
- }
- }) {
- Label("Open in AniList", systemImage: "link")
- }
- }
-
- Divider()
-
- Button(action: {
- Logger.shared.log("Debug Info:\nTitle: \(title)\nHref: \(href)\nModule: \(module.metadata.sourceName)\nAniList ID: \(itemID ?? -1)\nCustom ID: \(customAniListID ?? -1)", type: "Debug")
- DropManager.shared.showDrop(title: "Debug Info Logged", subtitle: "", duration: 1.0, icon: UIImage(systemName: "terminal"))
- }) {
- Label("Log Debug Info", systemImage: "terminal")
- }
- } label: {
- Image(systemName: "ellipsis.circle")
+ Image(systemName: "safari")
.resizable()
.frame(width: 20, height: 20)
.foregroundColor(.primary)
}
- }
- }
- }
-
- if !synopsis.isEmpty {
- VStack(alignment: .leading, spacing: 2) {
- HStack(alignment: .center) {
- Text("Synopsis")
- .font(.system(size: 18))
- .fontWeight(.bold)
-
- Spacer()
-
- Button(action: {
- showFullSynopsis.toggle()
- }) {
- Text(showFullSynopsis ? "Less" : "More")
- .font(.system(size: 14))
- }
+ .padding(4)
+ .background(Capsule().fill(Color.accentColor.opacity(0.4)))
}
- Text(synopsis)
- .lineLimit(showFullSynopsis ? nil : 4)
- .font(.system(size: 14))
- }
- }
-
- HStack {
- Button(action: {
- playFirstUnwatchedEpisode()
- }) {
- HStack {
- Image(systemName: "play.fill")
- .foregroundColor(.primary)
- Text(startWatchingText)
- .font(.headline)
+ Menu {
+ Button(action: {
+ showCustomIDAlert()
+ }) {
+ Label("Set Custom AniList ID", systemImage: "number")
+ }
+
+ if let customID = customAniListID {
+ Button(action: {
+ customAniListID = nil
+ itemID = nil
+ fetchItemID(byTitle: cleanTitle(title)) { result in
+ switch result {
+ case .success(let id):
+ itemID = id
+ case .failure(let error):
+ Logger.shared.log("Failed to fetch AniList ID: \(error)")
+ }
+ }
+ }) {
+ Label("Reset AniList ID", systemImage: "arrow.clockwise")
+ }
+ }
+
+ if let id = itemID ?? customAniListID {
+ Button(action: {
+ if let url = URL(string: "https://anilist.co/anime/\(id)") {
+ openSafariViewController(with: url.absoluteString)
+ }
+ }) {
+ Label("Open in AniList", systemImage: "link")
+ }
+ }
+
+ Divider()
+
+ Button(action: {
+ Logger.shared.log("Debug Info:\nTitle: \(title)\nHref: \(href)\nModule: \(module.metadata.sourceName)\nAniList ID: \(itemID ?? -1)\nCustom ID: \(customAniListID ?? -1)", type: "Debug")
+ DropManager.shared.showDrop(title: "Debug Info Logged", subtitle: "", duration: 1.0, icon: UIImage(systemName: "terminal"))
+ }) {
+ Label("Log Debug Info", systemImage: "terminal")
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ .resizable()
+ .frame(width: 20, height: 20)
.foregroundColor(.primary)
}
- .padding()
- .frame(maxWidth: .infinity)
- .background(Color.accentColor)
- .cornerRadius(10)
- }
- .disabled(isFetchingEpisode)
- .id(buttonRefreshTrigger)
-
- Button(action: {
- libraryManager.toggleBookmark(
- title: title,
- imageUrl: imageUrl,
- href: href,
- moduleId: module.id.uuidString,
- moduleName: module.metadata.sourceName
- )
- }) {
- Image(systemName: libraryManager.isBookmarked(href: href, moduleName: module.metadata.sourceName) ? "bookmark.fill" : "bookmark")
- .resizable()
- .frame(width: 20, height: 27)
- .foregroundColor(Color.accentColor)
}
}
+ }
+
+ if !synopsis.isEmpty {
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(alignment: .center) {
+ Text("Synopsis")
+ .font(.system(size: 18))
+ .fontWeight(.bold)
+
+ Spacer()
+
+ Button(action: {
+ showFullSynopsis.toggle()
+ }) {
+ Text(showFullSynopsis ? "Less" : "More")
+ .font(.system(size: 14))
+ }
+ }
+
+ Text(synopsis)
+ .lineLimit(showFullSynopsis ? nil : 4)
+ .font(.system(size: 14))
+ }
+ }
+
+ HStack {
+ Button(action: {
+ playFirstUnwatchedEpisode()
+ }) {
+ HStack {
+ Image(systemName: "play.fill")
+ .foregroundColor(.primary)
+ Text(startWatchingText)
+ .font(.headline)
+ .foregroundColor(.primary)
+ }
+ .padding()
+ .frame(maxWidth: .infinity)
+ .background(Color.accentColor)
+ .cornerRadius(10)
+ }
+ .disabled(isFetchingEpisode)
+ .id(buttonRefreshTrigger)
- if !episodeLinks.isEmpty {
- VStack(alignment: .leading, spacing: 10) {
- HStack {
- Text("Episodes")
- .font(.system(size: 18))
- .fontWeight(.bold)
-
- Spacer()
-
- if !isGroupedBySeasons, episodeLinks.count > episodeChunkSize {
+ Button(action: {
+ libraryManager.toggleBookmark(
+ title: title,
+ imageUrl: imageUrl,
+ href: href,
+ moduleId: module.id.uuidString,
+ moduleName: module.metadata.sourceName
+ )
+ }) {
+ Image(systemName: libraryManager.isBookmarked(href: href, moduleName: module.metadata.sourceName) ? "bookmark.fill" : "bookmark")
+ .resizable()
+ .frame(width: 20, height: 27)
+ .foregroundColor(Color.accentColor)
+ }
+ }
+
+ if !episodeLinks.isEmpty {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ Text("Episodes")
+ .font(.system(size: 18))
+ .fontWeight(.bold)
+
+ Spacer()
+
+ if !isGroupedBySeasons, episodeLinks.count > episodeChunkSize {
+ Menu {
+ ForEach(generateRanges(), id: \.self) { range in
+ Button(action: { selectedRange = range }) {
+ Text("\(range.lowerBound + 1)-\(range.upperBound)")
+ }
+ }
+ } label: {
+ Text("\(selectedRange.lowerBound + 1)-\(selectedRange.upperBound)")
+ .font(.system(size: 14))
+ .foregroundColor(.accentColor)
+ }
+ } else if isGroupedBySeasons {
+ let seasons = groupedEpisodes()
+ if seasons.count > 1 {
Menu {
- ForEach(generateRanges(), id: \.self) { range in
- Button(action: { selectedRange = range }) {
- Text("\(range.lowerBound + 1)-\(range.upperBound)")
+ ForEach(0.. 1 {
- Menu {
- ForEach(0.. 0 ? lastPlayedTime / totalTime : 0
-
- EpisodeCell(
- episodeIndex: selectedSeason,
- episode: ep.href,
- episodeID: ep.number - 1,
- progress: progress,
- itemID: itemID ?? 0,
- onTap: { imageUrl in
- if !isFetchingEpisode {
- selectedEpisodeNumber = ep.number
- selectedEpisodeImage = imageUrl
- fetchStream(href: ep.href)
- AnalyticsManager.shared.sendEvent(
- event: "watch",
- additionalData: ["title": title, "episode": ep.number]
- )
- }
- },
- onMarkAllPrevious: {
- let userDefaults = UserDefaults.standard
- var updates = [String: Double]()
-
- for ep2 in seasons[selectedSeason] where ep2.number < ep.number {
- let href = ep2.href
- updates["lastPlayedTime_\(href)"] = 99999999.0
- updates["totalTime_\(href)"] = 99999999.0
- }
-
- for (key, value) in updates {
- userDefaults.set(value, forKey: key)
- }
-
- userDefaults.synchronize()
-
- refreshTrigger.toggle()
- Logger.shared.log("Marked episodes watched within season \(selectedSeason + 1) of \"\(title)\".", type: "General")
- }
- )
- .id(refreshTrigger)
- .disabled(isFetchingEpisode)
- }
- } else {
- Text("No episodes available")
- }
- } else {
- ForEach(episodeLinks.indices.filter { selectedRange.contains($0) }, id: \.self) { i in
- let ep = episodeLinks[i]
+ }
+ if isGroupedBySeasons {
+ let seasons = groupedEpisodes()
+ if !seasons.isEmpty, selectedSeason < seasons.count {
+ ForEach(seasons[selectedSeason]) { ep in
let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)")
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)")
let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0
EpisodeCell(
- episodeIndex: i,
+ episodeIndex: selectedSeason,
episode: ep.href,
episodeID: ep.number - 1,
progress: progress,
@@ -365,150 +315,163 @@ struct MediaInfoView: View {
let userDefaults = UserDefaults.standard
var updates = [String: Double]()
- for idx in 0.. 0 ? lastPlayedTime / totalTime : 0
+
+ EpisodeCell(
+ episodeIndex: i,
+ episode: ep.href,
+ episodeID: ep.number - 1,
+ progress: progress,
+ itemID: itemID ?? 0,
+ onTap: { imageUrl in
+ if !isFetchingEpisode {
+ selectedEpisodeNumber = ep.number
+ selectedEpisodeImage = imageUrl
+ fetchStream(href: ep.href)
+ AnalyticsManager.shared.sendEvent(
+ event: "watch",
+ additionalData: ["title": title, "episode": ep.number]
+ )
+ }
+ },
+ onMarkAllPrevious: {
+ let userDefaults = UserDefaults.standard
+ var updates = [String: Double]()
+
+ for idx in 0.. Void = { result in
+ guard self.activeFetchID == fetchID else { return }
+ self.showLoadingAlert = false
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
+ if let streams = result.sources, !streams.isEmpty {
+ if streams.count > 1 {
+ self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
+ } else {
+ self.playStream(url: streams[0]["streamUrl"] as? String ?? "", fullURL: href, subtitles: streams[0]["subtitle"] as? String ?? "", headers: streams[0]["headers"] as! [String : String])
+ }
+ }
+ else if let streams = result.streams, !streams.isEmpty {
+ if streams.count > 1 {
+ self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
+ } else {
+ self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first)
+ }
+ } else {
+ self.handleStreamFailure(error: nil)
+ }
+
+ DispatchQueue.main.async {
+ self.isFetchingEpisode = false
+ }
+ }
+ }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
Task {
do {
let jsContent = try moduleManager.getModuleContent(module)
jsController.loadScript(jsContent)
-
- if module.metadata.softsub == true {
- if module.metadata.asyncJS == true {
- jsController.fetchStreamUrlJS(episodeUrl: href, softsub: true, module: module) { result in
- guard self.activeFetchID == fetchID else { return }
-
- if let streams = result.streams, !streams.isEmpty {
- if streams.count > 1 {
- self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
- } else {
- self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first)
- }
- } else {
- self.handleStreamFailure(error: nil)
- }
- DispatchQueue.main.async {
- self.isFetchingEpisode = false
- }
- }
- } else if module.metadata.streamAsyncJS == true {
- jsController.fetchStreamUrlJSSecond(episodeUrl: href, softsub: true, module: module) { result in
- guard self.activeFetchID == fetchID else { return }
-
- if let streams = result.streams, !streams.isEmpty {
- if streams.count > 1 {
- self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
- } else {
- self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first)
- }
- } else {
- self.handleStreamFailure(error: nil)
- }
- DispatchQueue.main.async {
- self.isFetchingEpisode = false
- }
- }
- } else {
- jsController.fetchStreamUrl(episodeUrl: href, softsub: true, module: module) { result in
- guard self.activeFetchID == fetchID else { return }
-
- if let streams = result.streams, !streams.isEmpty {
- if streams.count > 1 {
- self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
- } else {
- self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first)
- }
- } else {
- self.handleStreamFailure(error: nil)
- }
- DispatchQueue.main.async {
- self.isFetchingEpisode = false
- }
- }
- }
+ if module.metadata.asyncJS == true {
+ jsController.fetchStreamUrlJS(episodeUrl: href, softsub: module.metadata.softsub == true, module: module, completion: completion)
+ } else if module.metadata.streamAsyncJS == true {
+ jsController.fetchStreamUrlJSSecond(episodeUrl: href, softsub: module.metadata.softsub == true, module: module, completion: completion)
} else {
- if module.metadata.asyncJS == true {
- jsController.fetchStreamUrlJS(episodeUrl: href, module: module) { result in
- guard self.activeFetchID == fetchID else { return }
-
- if let streams = result.streams, !streams.isEmpty {
- if streams.count > 1 {
- self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
- } else {
- self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first)
- }
- } else {
- self.handleStreamFailure(error: nil)
- }
- DispatchQueue.main.async {
- self.isFetchingEpisode = false
- }
- }
- } else if module.metadata.streamAsyncJS == true {
- jsController.fetchStreamUrlJSSecond(episodeUrl: href, module: module) { result in
- guard self.activeFetchID == fetchID else { return }
-
- if let streams = result.streams, !streams.isEmpty {
- if streams.count > 1 {
- self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
- } else {
- self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first)
- }
- } else {
- self.handleStreamFailure(error: nil)
- }
- DispatchQueue.main.async {
- self.isFetchingEpisode = false
- }
- }
- } else {
- jsController.fetchStreamUrl(episodeUrl: href, module: module) { result in
- guard self.activeFetchID == fetchID else { return }
-
- if let streams = result.streams, !streams.isEmpty {
- if streams.count > 1 {
- self.showStreamSelectionAlert(streams: streams, fullURL: href, subtitles: result.subtitles?.first)
- } else {
- self.playStream(url: streams[0], fullURL: href, subtitles: result.subtitles?.first)
- }
- } else {
- self.handleStreamFailure(error: nil)
- }
- DispatchQueue.main.async {
- self.isFetchingEpisode = false
- }
- }
- }
+ jsController.fetchStreamUrl(episodeUrl: href, softsub: module.metadata.softsub == true, module: module, completion: completion)
}
+
} catch {
self.handleStreamFailure(error: error)
DispatchQueue.main.async {
@@ -768,7 +658,7 @@ struct MediaInfoView: View {
func handleStreamFailure(error: Error? = nil) {
self.isFetchingEpisode = false
- self.showStreamLoadingView = false
+ self.showLoadingAlert = false
if let error = error {
Logger.shared.log("Error loading module: \(error)", type: "Error")
AnalyticsManager.shared.sendEvent(event: "error", additionalData: ["error": error, "message": "Failed to fetch stream"])
@@ -779,37 +669,57 @@ struct MediaInfoView: View {
self.isLoading = false
}
- func showStreamSelectionAlert(streams: [String], fullURL: String, subtitles: String? = nil) {
+ func showStreamSelectionAlert(streams: [Any], fullURL: String, subtitles: String? = nil) {
self.isFetchingEpisode = false
- self.showStreamLoadingView = false
- DispatchQueue.main.async {
+ self.showLoadingAlert = false
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
let alert = UIAlertController(title: "Select Server", message: "Choose a server to play from", preferredStyle: .actionSheet)
var index = 0
var streamIndex = 1
while index < streams.count {
- let title: String
- let streamUrl: String
-
- if index + 1 < streams.count {
- if !streams[index].lowercased().contains("http") {
- title = streams[index]
- streamUrl = streams[index + 1]
- index += 2
+ var title: String = ""
+ var streamUrl: String = ""
+ var headers: [String:String]? = nil
+ if let streams = streams as? [String]
+ {
+ if index + 1 < streams.count {
+ if !streams[index].lowercased().contains("http") {
+ title = streams[index]
+ streamUrl = streams[index + 1]
+ index += 2
+ } else {
+ title = "Stream \(streamIndex)"
+ streamUrl = streams[index]
+ index += 1
+ }
} else {
title = "Stream \(streamIndex)"
streamUrl = streams[index]
index += 1
}
- } else {
- title = "Stream \(streamIndex)"
- streamUrl = streams[index]
+ }
+ else if let streams = streams as? [[String: Any]]
+ {
+ if let currTitle = streams[index]["title"] as? String
+ {
+ title = currTitle
+ streamUrl = (streams[index]["streamUrl"] as? String) ?? ""
+ }
+ else
+ {
+ title = "Stream \(streamIndex)"
+ streamUrl = (streams[index]["streamUrl"] as? String)!
+ }
+ headers = streams[index]["headers"] as? [String:String] ?? [:]
index += 1
}
+
alert.addAction(UIAlertAction(title: title, style: .default) { _ in
- self.playStream(url: streamUrl, fullURL: fullURL, subtitles: subtitles)
+ self.playStream(url: streamUrl, fullURL: fullURL, subtitles: subtitles,headers: headers)
})
streamIndex += 1
@@ -843,10 +753,11 @@ struct MediaInfoView: View {
}
}
- func playStream(url: String, fullURL: String, subtitles: String? = nil) {
+ func playStream(url: String, fullURL: String, subtitles: String? = nil, headers: [String:String]? = nil) {
self.isFetchingEpisode = false
- self.showStreamLoadingView = false
- DispatchQueue.main.async {
+ self.showLoadingAlert = false
+
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
let externalPlayer = UserDefaults.standard.string(forKey: "externalPlayer") ?? "Sora"
var scheme: String?
@@ -860,9 +771,12 @@ struct MediaInfoView: View {
case "nPlayer":
scheme = "nplayer-\(url)"
case "SenPlayer":
- scheme = "SenPlayer://x-callback-url/play?url=\(url)"
+ scheme = "senplayer://x-callback-url/play?url=\(url)"
+ case "IINA":
+ scheme = "iina://weblink?url=\(url)"
case "Default":
let videoPlayerViewController = VideoPlayerViewController(module: module)
+ videoPlayerViewController.headers = headers
videoPlayerViewController.streamUrl = url
videoPlayerViewController.fullUrl = fullURL
videoPlayerViewController.episodeNumber = selectedEpisodeNumber
@@ -902,7 +816,8 @@ struct MediaInfoView: View {
},
subtitlesURL: subtitles,
aniListID: itemID ?? 0,
- episodeImageUrl: selectedEpisodeImage
+ episodeImageUrl: selectedEpisodeImage,
+ headers: headers ?? nil
)
customMediaPlayer.modalPresentationStyle = .fullScreen
Logger.shared.log("Opening custom media player with url: \(url)")
diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift
index bfbf687..42901d5 100644
--- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift
+++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift
@@ -11,12 +11,32 @@ struct SettingsViewData: View {
@State private var showEraseAppDataAlert = false
@State private var showRemoveDocumentsAlert = false
@State private var showSizeAlert = false
+ @State private var cacheSize: Int64 = 0
+ @State private var documentsSize: Int64 = 0
var body: some View {
Form {
Section(header: Text("App storage"), footer: Text("The caches used by Sora are stored images that help load content faster\n\nThe App Data should never be erased if you dont know what that will cause.\n\nClearing the documents folder will remove all the modules and downloads")) {
- Button(action: clearCache) {
- Text("Clear Cache")
+ HStack {
+ Button(action: clearCache) {
+ Text("Clear Cache")
+ }
+ Spacer()
+ Text("\(formatSize(cacheSize))")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+
+ HStack {
+ Button(action: {
+ showRemoveDocumentsAlert = true
+ }) {
+ Text("Remove All Files in Documents")
+ }
+ Spacer()
+ Text("\(formatSize(documentsSize))")
+ .font(.subheadline)
+ .foregroundColor(.secondary)
}
Button(action: {
@@ -24,36 +44,13 @@ struct SettingsViewData: View {
}) {
Text("Erase all App Data")
}
- .alert(isPresented: $showEraseAppDataAlert) {
- Alert(
- title: Text("Confirm Erase App Data"),
- message: Text("Are you sure you want to erase all app data? This action cannot be undone. (The app will then close)"),
- primaryButton: .destructive(Text("Erase")) {
- eraseAppData()
- },
- secondaryButton: .cancel()
- )
- }
-
- Button(action: {
- showRemoveDocumentsAlert = true
- }) {
- Text("Remove All Files in Documents")
- }
- .alert(isPresented: $showRemoveDocumentsAlert) {
- Alert(
- title: Text("Confirm Remove All Files"),
- message: Text("Are you sure you want to remove all files in the documents folder? This will also remove all modules and you will lose the favorite items. This action cannot be undone. (The app will then close)"),
- primaryButton: .destructive(Text("Remove")) {
- removeAllFilesInDocuments()
- },
- secondaryButton: .cancel()
- )
- }
}
}
.navigationTitle("App Data")
.navigationViewStyle(StackNavigationViewStyle())
+ .onAppear {
+ updateSizes()
+ }
}
func eraseAppData() {
@@ -75,6 +72,7 @@ struct SettingsViewData: View {
try FileManager.default.removeItem(at: filePath)
}
Logger.shared.log("Cache cleared successfully!", type: "General")
+ updateSizes()
}
} catch {
Logger.shared.log("Failed to clear cache.", type: "Error")
@@ -96,4 +94,42 @@ struct SettingsViewData: View {
}
}
}
+
+ private 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
+ }
+
+ private func formatSize(_ bytes: Int64) -> String {
+ let formatter = ByteCountFormatter()
+ formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB]
+ formatter.countStyle = .file
+ return formatter.string(fromByteCount: bytes)
+ }
+
+ private func updateSizes() {
+ if let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
+ cacheSize = calculateDirectorySize(for: cacheURL)
+ }
+
+ if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
+ documentsSize = calculateDirectorySize(for: documentsURL)
+ }
+ }
}
diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift
index 28b6d77..7bce329 100644
--- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift
+++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift
@@ -19,16 +19,27 @@ struct SettingsViewPlayer: View {
@AppStorage("doubleTapSeekEnabled") private var doubleTapSeekEnabled: Bool = false
@AppStorage("skipIntroOutroVisible") private var skipIntroOutroVisible: Bool = true
- private let mediaPlayers = ["Default", "VLC", "OutPlayer", "Infuse", "nPlayer", "SenPlayer", "Sora"]
+ private let mediaPlayers = ["Default", "Sora", "VLC", "OutPlayer", "Infuse", "nPlayer", "SenPlayer", "IINA"]
var body: some View {
Form {
- Section(header: Text("Media Player"), footer: Text("Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.")) {
- HStack {
- Text("Media Player")
- Spacer()
- Menu(externalPlayer) {
- ForEach(mediaPlayers, id: \.self) { player in
+ Section(header: Text("Media Player"), footer: Text("Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.")) {
+ HStack {
+ Text("Media Player")
+ Spacer()
+ Menu(externalPlayer) {
+ Menu("In-App Players") {
+ ForEach(mediaPlayers.prefix(2), id: \.self) { player in
+ Button(action: {
+ externalPlayer = player
+ }) {
+ Text(player)
+ }
+ }
+ }
+
+ Menu("External Players") {
+ ForEach(mediaPlayers.dropFirst(2), id: \.self) { player in
Button(action: {
externalPlayer = player
}) {
@@ -37,6 +48,7 @@ struct SettingsViewPlayer: View {
}
}
}
+ }
Toggle("Force Landscape", isOn: $isAlwaysLandscape)
.tint(.accentColor)
diff --git a/Sora/Views/SettingsView/SettingsView.swift b/Sora/Views/SettingsView/SettingsView.swift
index e156260..6db65f9 100644
--- a/Sora/Views/SettingsView/SettingsView.swift
+++ b/Sora/Views/SettingsView/SettingsView.swift
@@ -81,7 +81,7 @@ struct SettingsView: View {
}
}) {
HStack {
- Text("License (GPLv3.0)")
+ Text("Licensed under GPLv3.0")
.foregroundColor(.primary)
Spacer()
Image(systemName: "safari")
diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj
index 4415b7b..39c37c4 100644
--- a/Sulfur.xcodeproj/project.pbxproj
+++ b/Sulfur.xcodeproj/project.pbxproj
@@ -176,6 +176,7 @@
131270152DC139CD0093AA9C /* DownloadManager */ = {
isa = PBXGroup;
children = (
+ 131270162DC13A010093AA9C /* DownloadManager.swift */,
);
path = DownloadManager;
sourceTree = "";
@@ -374,7 +375,6 @@
children = (
13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */,
13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */,
- 131270162DC13A010093AA9C /* DownloadManager.swift */,
);
path = ContinueWatching;
sourceTree = "";
@@ -741,7 +741,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 0.2.2;
+ MARKETING_VERSION = 0.2.3;
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -784,7 +784,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 0.2.2;
+ MARKETING_VERSION = 0.2.3;
PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";