* 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:
cranci 2025-05-10 16:29:46 +02:00 committed by GitHub
parent a77b57e3d2
commit 1a4078d2c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 700 additions and 623 deletions

View file

@ -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 @@
[![Build and Release IPA](https://github.com/cranci1/Sora/actions/workflows/build.yml/badge.svg)](https://github.com/cranci1/Sora/actions/workflows/build.yml) [![Discord](https://img.shields.io/discord/1293430817841741899.svg?logo=discord&color=blue)](https://discord.gg/XR3SrmUbpd) [![Platform](https://img.shields.io/badge/Platform-iOS%20%7C%20iPadOS%2015.0%2B%20%26%20macOS%2012.0%2B-red?logo=apple&logoColor=white)](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.

View file

@ -21,6 +21,7 @@
</array>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>iina</string>
<string>outplayer</string>
<string>infuse</string>
<string>vlc</string>

View file

@ -18,4 +18,5 @@ struct ContinueWatchingItem: Codable, Identifiable {
let subtitles: String?
let aniListID: Int?
let module: ScrapingModule
let headers: [String:String]?
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -81,7 +81,7 @@ struct SettingsView: View {
}
}) {
HStack {
Text("License (GPLv3.0)")
Text("Licensed under GPLv3.0")
.foregroundColor(.primary)
Spacer()
Image(systemName: "safari")

View file

@ -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 = "";