mirror of
https://github.com/cranci1/Sora.git
synced 2026-03-11 17:45:37 +00:00
0.2.3 (#126)
* add debounce to SearchBar
* add debounce to searchBar
* test download
* provided headers for streams
* Revert "test download"
This reverts commit 72fce2f14b.
* Real branch for #90 (#122)
* Add App Store Link
* Add App Store icon
* Add Testflight link and icon
* Fixed icon sizing
* typo
* fixed readme
---------
Co-authored-by: Storm <stormisfjeld@gmail.com>
* Update README.md
* allow string as post body data type
* Update README.md
* ops
* Update README.md
* Update README.md
* frrfrfr
* Update README.md
* fixed speed on pause
* Update README.md
* Update README.md
* test
* Update README.md (#125)
interesting
* logger in main thread
* fixed segments crash if show is longer
* fixed issues
---------
Co-authored-by: DawudOsman <d.osman@outlook.com>
Co-authored-by: D Osman <80430633+DawudOsman@users.noreply.github.com>
Co-authored-by: Storm <stormisfjeld@gmail.com>
Co-authored-by: Bshar Esfky <98615778+bshar1865@users.noreply.github.com>
This commit is contained in:
parent
a77b57e3d2
commit
1a4078d2c2
15 changed files with 700 additions and 623 deletions
35
README.md
35
README.md
|
|
@ -1,5 +1,5 @@
|
|||
# Sora
|
||||
> Also known as Sulfur, for copyright issues.
|
||||
> Also known as Sulfur due to copyright considerations.
|
||||
|
||||
<div align="center">
|
||||
|
||||
|
|
@ -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.**
|
||||
|
||||
</div>
|
||||
|
||||
## 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):
|
||||
|
||||
<a href="https://apps.apple.com/us/app/sulfur/id6742741043">
|
||||
<img src="https://askyourself.app/assets/appstore.png" width="170" alt="Build and Release IPA">
|
||||
</a>
|
||||
|
||||
<a href="https://testflight.apple.com/join/qMUCpNaS">
|
||||
<img src="https://askyourself.app/assets/testflight.png" width="170" alt="Build and Release IPA">
|
||||
</a>
|
||||
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
</array>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>iina</string>
|
||||
<string>outplayer</string>
|
||||
<string>infuse</string>
|
||||
<string>vlc</string>
|
||||
|
|
|
|||
|
|
@ -18,4 +18,5 @@ struct ContinueWatchingItem: Codable, Identifiable {
|
|||
let subtitles: String?
|
||||
let aniListID: Int?
|
||||
let module: ScrapingModule
|
||||
let headers: [String:String]?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ struct SettingsView: View {
|
|||
}
|
||||
}) {
|
||||
HStack {
|
||||
Text("License (GPLv3.0)")
|
||||
Text("Licensed under GPLv3.0")
|
||||
.foregroundColor(.primary)
|
||||
Spacer()
|
||||
Image(systemName: "safari")
|
||||
|
|
|
|||
|
|
@ -176,6 +176,7 @@
|
|||
131270152DC139CD0093AA9C /* DownloadManager */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
131270162DC13A010093AA9C /* DownloadManager.swift */,
|
||||
);
|
||||
path = DownloadManager;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -374,7 +375,6 @@
|
|||
children = (
|
||||
13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */,
|
||||
13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */,
|
||||
131270162DC13A010093AA9C /* DownloadManager.swift */,
|
||||
);
|
||||
path = ContinueWatching;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -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 = "";
|
||||
|
|
|
|||
Loading…
Reference in a new issue