diff --git a/Sora/ContentView.swift b/Sora/ContentView.swift
index 01fca9c..afbd4ea 100644
--- a/Sora/ContentView.swift
+++ b/Sora/ContentView.swift
@@ -11,10 +11,6 @@ import Kingfisher
struct ContentView: View {
var body: some View {
TabView {
- HomeView()
- .tabItem {
- Label("Home", systemImage: "house")
- }
LibraryView()
.tabItem {
Label("Library", systemImage: "books.vertical")
diff --git a/Sora/Info.plist b/Sora/Info.plist
index 6fbda5f..4c37c9d 100644
--- a/Sora/Info.plist
+++ b/Sora/Info.plist
@@ -30,6 +30,7 @@
UIBackgroundModes
audio
+ processing
diff --git a/Sora/Tracking Services/TMDB/HomePage/TMDB-Seasonal.swift b/Sora/Tracking Services/TMDB/HomePage/TMDB-Seasonal.swift
index c4a8085..b28a512 100644
--- a/Sora/Tracking Services/TMDB/HomePage/TMDB-Seasonal.swift
+++ b/Sora/Tracking Services/TMDB/HomePage/TMDB-Seasonal.swift
@@ -19,7 +19,6 @@ class TMDBSeasonal {
var request = URLRequest(url: components.url!)
let token = TMBDRequest.getToken()
- print(token)
request.allHTTPHeaderFields = [
"accept": "application/json",
diff --git a/Sora/Utils/DownloadManager/DownloadManager.swift b/Sora/Utils/DownloadManager/DownloadManager.swift
new file mode 100644
index 0000000..d86e355
--- /dev/null
+++ b/Sora/Utils/DownloadManager/DownloadManager.swift
@@ -0,0 +1,215 @@
+//
+// DownloadManager.swift
+// Sulfur
+//
+// Created by Francesco on 09/03/25.
+//
+
+import Foundation
+import FFmpegSupport
+import UIKit
+
+extension Notification.Name {
+ static let DownloadManagerStatusUpdate = Notification.Name("DownloadManagerStatusUpdate")
+}
+
+class DownloadManager {
+ static let shared = DownloadManager()
+
+ private var backgroundTaskIdentifier: UIBackgroundTaskIdentifier = .invalid
+ private var activeConversions = [String: Bool]()
+
+ private init() {
+ NotificationCenter.default.addObserver(self, selector: #selector(applicationWillResignActive), name: UIApplication.willResignActiveNotification, object: nil)
+ }
+
+ @objc private func applicationWillResignActive() {
+ if !activeConversions.isEmpty {
+ backgroundTaskIdentifier = UIApplication.shared.beginBackgroundTask { [weak self] in
+ self?.endBackgroundTask()
+ }
+ }
+ }
+
+ private func endBackgroundTask() {
+ if backgroundTaskIdentifier != .invalid {
+ UIApplication.shared.endBackgroundTask(backgroundTaskIdentifier)
+ backgroundTaskIdentifier = .invalid
+ }
+ }
+
+ func downloadAndConvertHLS(from url: URL, title: String, episode: Int, subtitleURL: URL? = nil, module: ScrapingModule, completion: @escaping (Bool, URL?) -> Void) {
+ guard let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else {
+ completion(false, nil)
+ return
+ }
+
+ let folderURL = documentsDirectory.appendingPathComponent(title + "-" + module.metadata.sourceName)
+ if (!FileManager.default.fileExists(atPath: folderURL.path)) {
+ do {
+ try FileManager.default.createDirectory(at: folderURL, withIntermediateDirectories: true, attributes: nil)
+ } catch {
+ Logger.shared.log("Error creating folder: \(error)")
+ completion(false, nil)
+ return
+ }
+ }
+
+ let outputFileName = "\(title)_Episode\(episode)_\(module.metadata.sourceName).mp4"
+ let outputFileURL = folderURL.appendingPathComponent(outputFileName)
+
+ let fileExtension = url.pathExtension.lowercased()
+
+ if fileExtension == "mp4" {
+ NotificationCenter.default.post(name: .DownloadManagerStatusUpdate, object: nil, userInfo: [
+ "title": title,
+ "episode": episode,
+ "type": "mp4",
+ "status": "Downloading",
+ "progress": 0.0
+ ])
+
+ let task = URLSession.custom.downloadTask(with: url) { tempLocalURL, response, error in
+ if let tempLocalURL = tempLocalURL {
+ do {
+ try FileManager.default.moveItem(at: tempLocalURL, to: outputFileURL)
+ NotificationCenter.default.post(name: .DownloadManagerStatusUpdate, object: nil, userInfo: [
+ "title": title,
+ "episode": episode,
+ "type": "mp4",
+ "status": "Completed",
+ "progress": 1.0
+ ])
+ DispatchQueue.main.async {
+ Logger.shared.log("Download successful: \(outputFileURL)")
+ completion(true, outputFileURL)
+ }
+ } catch {
+ DispatchQueue.main.async {
+ Logger.shared.log("Download failed: \(error)")
+ completion(false, nil)
+ }
+ }
+ } else {
+ DispatchQueue.main.async {
+ Logger.shared.log("Download failed: \(error?.localizedDescription ?? "Unknown error")")
+ completion(false, nil)
+ }
+ }
+ }
+ task.resume()
+ } else if fileExtension == "m3u8" {
+ let conversionKey = "\(title)_\(episode)_\(module.metadata.sourceName)"
+ activeConversions[conversionKey] = true
+
+ if UIApplication.shared.applicationState != .active && backgroundTaskIdentifier == .invalid {
+ backgroundTaskIdentifier = UIApplication.shared.beginBackgroundTask { [weak self] in
+ self?.endBackgroundTask()
+ }
+ }
+
+ DispatchQueue.global(qos: .background).async {
+ NotificationCenter.default.post(name: .DownloadManagerStatusUpdate, object: nil, userInfo: [
+ "title": title,
+ "episode": episode,
+ "type": "hls",
+ "status": "Converting",
+ "progress": 0.0
+ ])
+
+ let processorCount = ProcessInfo.processInfo.processorCount
+ let physicalMemory = ProcessInfo.processInfo.physicalMemory / (1024 * 1024)
+
+ var ffmpegCommand = ["ffmpeg", "-y"]
+
+ ffmpegCommand.append(contentsOf: ["-protocol_whitelist", "file,http,https,tcp,tls"])
+
+ ffmpegCommand.append(contentsOf: ["-fflags", "+genpts"])
+ ffmpegCommand.append(contentsOf: ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"])
+ ffmpegCommand.append(contentsOf: ["-headers", "Referer: \(module.metadata.baseUrl)"])
+
+ let multiThreads = UserDefaults.standard.bool(forKey: "multiThreads")
+ if multiThreads {
+ let threadCount = max(2, processorCount - 1)
+ ffmpegCommand.append(contentsOf: ["-threads", "\(threadCount)"])
+ } else {
+ ffmpegCommand.append(contentsOf: ["-threads", "2"])
+ }
+
+ let bufferSize = min(32, max(8, Int(physicalMemory) / 256))
+ ffmpegCommand.append(contentsOf: ["-bufsize", "\(bufferSize)M"])
+ ffmpegCommand.append(contentsOf: ["-i", url.absoluteString])
+
+ if let subtitleURL = subtitleURL {
+ do {
+ let subtitleData = try Data(contentsOf: subtitleURL)
+ let subtitleFileExtension = subtitleURL.pathExtension.lowercased()
+ if subtitleFileExtension != "srt" && subtitleFileExtension != "vtt" {
+ Logger.shared.log("Unsupported subtitle format: \(subtitleFileExtension)")
+ }
+ let subtitleFileName = "\(title)_Episode\(episode).\(subtitleFileExtension)"
+ let subtitleLocalURL = folderURL.appendingPathComponent(subtitleFileName)
+ try subtitleData.write(to: subtitleLocalURL)
+ ffmpegCommand.append(contentsOf: ["-i", subtitleLocalURL.path])
+
+ ffmpegCommand.append(contentsOf: [
+ "-c:v", "copy",
+ "-c:a", "copy",
+ "-c:s", "mov_text",
+ "-disposition:s:0", "default+forced",
+ "-metadata:s:s:0", "handler_name=English",
+ "-metadata:s:s:0", "language=eng"
+ ])
+
+ ffmpegCommand.append(outputFileURL.path)
+ } catch {
+ Logger.shared.log("Subtitle download failed: \(error)")
+ ffmpegCommand.append(contentsOf: ["-c:v", "copy", "-c:a", "copy"])
+ ffmpegCommand.append(contentsOf: ["-movflags", "+faststart"])
+ ffmpegCommand.append(outputFileURL.path)
+ }
+ } else {
+ ffmpegCommand.append(contentsOf: ["-c:v", "copy", "-c:a", "copy"])
+ ffmpegCommand.append(contentsOf: ["-movflags", "+faststart"])
+ ffmpegCommand.append(outputFileURL.path)
+ }
+ Logger.shared.log("FFmpeg command: \(ffmpegCommand.joined(separator: " "))", type: "Debug")
+
+ NotificationCenter.default.post(name: .DownloadManagerStatusUpdate, object: nil, userInfo: [
+ "title": title,
+ "episode": episode,
+ "type": "hls",
+ "status": "Converting",
+ "progress": 0.5
+ ])
+
+ let success = ffmpeg(ffmpegCommand)
+ DispatchQueue.main.async { [weak self] in
+ if success == 0 {
+ NotificationCenter.default.post(name: .DownloadManagerStatusUpdate, object: nil, userInfo: [
+ "title": title,
+ "episode": episode,
+ "type": "hls",
+ "status": "Completed",
+ "progress": 1.0
+ ])
+ Logger.shared.log("Conversion successful: \(outputFileURL)")
+ completion(true, outputFileURL)
+ } else {
+ Logger.shared.log("Conversion failed")
+ completion(false, nil)
+ }
+
+ self?.activeConversions[conversionKey] = nil
+
+ if self?.activeConversions.isEmpty ?? true {
+ self?.endBackgroundTask()
+ }
+ }
+ }
+ } else {
+ Logger.shared.log("Unsupported file type: \(fileExtension)")
+ completion(false, nil)
+ }
+ }
+}
diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift
index e3c8713..612039f 100644
--- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift
+++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift
@@ -61,6 +61,14 @@ class CustomMediaPlayerViewController: UIViewController {
var watchNextButtonControlsConstraints: [NSLayoutConstraint] = []
var isControlsVisible = false
+ var subtitleBottomConstraint: NSLayoutConstraint?
+
+ var subtitleBottomPadding: CGFloat = 10.0 {
+ didSet {
+ updateSubtitleLabelConstraints()
+ }
+ }
+
init(module: ScrapingModule,
urlString: String,
fullUrl: String,
@@ -85,9 +93,9 @@ class CustomMediaPlayerViewController: UIViewController {
fatalError("Invalid URL string")
}
var request = URLRequest(url: url)
- if urlString.contains("ascdn") {
- request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
- }
+ request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
+ 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 ?? [:]])
let playerItem = AVPlayerItem(asset: asset)
self.player = AVPlayer(playerItem: playerItem)
@@ -106,6 +114,10 @@ class CustomMediaPlayerViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
+
+ // Load persistent subtitle settings on launch
+ loadSubtitleSettings()
+
setupPlayerViewController()
setupControls()
setupSubtitleLabel()
@@ -256,12 +268,12 @@ class CustomMediaPlayerViewController: UIViewController {
playPauseButton.heightAnchor.constraint(equalToConstant: 50),
backwardButton.centerYAnchor.constraint(equalTo: playPauseButton.centerYAnchor),
- backwardButton.trailingAnchor.constraint(equalTo: playPauseButton.leadingAnchor, constant: -30),
+ backwardButton.trailingAnchor.constraint(equalTo: playPauseButton.leadingAnchor, constant: -50),
backwardButton.widthAnchor.constraint(equalToConstant: 40),
backwardButton.heightAnchor.constraint(equalToConstant: 40),
forwardButton.centerYAnchor.constraint(equalTo: playPauseButton.centerYAnchor),
- forwardButton.leadingAnchor.constraint(equalTo: playPauseButton.trailingAnchor, constant: 30),
+ forwardButton.leadingAnchor.constraint(equalTo: playPauseButton.trailingAnchor, constant: 50),
forwardButton.widthAnchor.constraint(equalToConstant: 40),
forwardButton.heightAnchor.constraint(equalToConstant: 40)
])
@@ -275,14 +287,25 @@ class CustomMediaPlayerViewController: UIViewController {
updateSubtitleLabelAppearance()
view.addSubview(subtitleLabel)
subtitleLabel.translatesAutoresizingMaskIntoConstraints = false
+
+ subtitleBottomConstraint = subtitleLabel.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -subtitleBottomPadding)
+
NSLayoutConstraint.activate([
subtitleLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
- subtitleLabel.bottomAnchor.constraint(equalTo: sliderHostingController?.view.bottomAnchor ?? view.safeAreaLayoutGuide.bottomAnchor),
+ subtitleBottomConstraint!,
subtitleLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor, constant: 36),
subtitleLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -36)
])
}
+ func updateSubtitleLabelConstraints() {
+ subtitleBottomConstraint?.constant = -subtitleBottomPadding
+ view.setNeedsLayout()
+ UIView.animate(withDuration: 0.2) {
+ self.view.layoutIfNeeded()
+ }
+ }
+
func setupDismissButton() {
dismissButton = UIButton(type: .system)
dismissButton.setImage(UIImage(systemName: "xmark"), for: .normal)
@@ -374,11 +397,7 @@ class CustomMediaPlayerViewController: UIViewController {
func updateSubtitleLabelAppearance() {
subtitleLabel.font = UIFont.systemFont(ofSize: CGFloat(subtitleFontSize))
subtitleLabel.textColor = subtitleUIColor()
- if subtitleBackgroundEnabled {
- subtitleLabel.backgroundColor = UIColor.black.withAlphaComponent(0.6)
- } else {
- subtitleLabel.backgroundColor = .clear
- }
+ subtitleLabel.backgroundColor = subtitleBackgroundEnabled ? UIColor.black.withAlphaComponent(0.6) : .clear
subtitleLabel.layer.cornerRadius = 5
subtitleLabel.clipsToBounds = true
subtitleLabel.layer.shadowColor = UIColor.black.cgColor
@@ -538,42 +557,120 @@ class CustomMediaPlayerViewController: UIViewController {
if let subURL = subtitlesURL, !subURL.isEmpty {
let foregroundActions = [
- UIAction(title: "White") { _ in self.subtitleForegroundColor = "white"; self.updateSubtitleLabelAppearance() },
- UIAction(title: "Yellow") { _ in self.subtitleForegroundColor = "yellow"; self.updateSubtitleLabelAppearance() },
- UIAction(title: "Green") { _ in self.subtitleForegroundColor = "green"; self.updateSubtitleLabelAppearance() },
- UIAction(title: "Blue") { _ in self.subtitleForegroundColor = "blue"; self.updateSubtitleLabelAppearance() },
- UIAction(title: "Red") { _ in self.subtitleForegroundColor = "red"; self.updateSubtitleLabelAppearance() },
- UIAction(title: "Purple") { _ in self.subtitleForegroundColor = "purple"; self.updateSubtitleLabelAppearance() }
+ UIAction(title: "White") { _ in
+ SubtitleSettingsManager.shared.update { settings in settings.foregroundColor = "white" }
+ self.loadSubtitleSettings()
+ self.updateSubtitleLabelAppearance()
+ },
+ UIAction(title: "Yellow") { _ in
+ SubtitleSettingsManager.shared.update { settings in settings.foregroundColor = "yellow" }
+ self.loadSubtitleSettings()
+ self.updateSubtitleLabelAppearance()
+ },
+ UIAction(title: "Green") { _ in
+ SubtitleSettingsManager.shared.update { settings in settings.foregroundColor = "green" }
+ self.loadSubtitleSettings()
+ self.updateSubtitleLabelAppearance()
+ },
+ UIAction(title: "Blue") { _ in
+ SubtitleSettingsManager.shared.update { settings in settings.foregroundColor = "blue" }
+ self.loadSubtitleSettings()
+ self.updateSubtitleLabelAppearance()
+ },
+ UIAction(title: "Red") { _ in
+ SubtitleSettingsManager.shared.update { settings in settings.foregroundColor = "red" }
+ self.loadSubtitleSettings()
+ self.updateSubtitleLabelAppearance()
+ },
+ UIAction(title: "Purple") { _ in
+ SubtitleSettingsManager.shared.update { settings in settings.foregroundColor = "purple" }
+ self.loadSubtitleSettings()
+ self.updateSubtitleLabelAppearance()
+ }
]
let colorMenu = UIMenu(title: "Subtitle Color", children: foregroundActions)
let fontSizeActions = [
- UIAction(title: "16") { _ in self.subtitleFontSize = 16; self.updateSubtitleLabelAppearance() },
- UIAction(title: "18") { _ in self.subtitleFontSize = 18; self.updateSubtitleLabelAppearance() },
- UIAction(title: "20") { _ in self.subtitleFontSize = 20; self.updateSubtitleLabelAppearance() },
- UIAction(title: "22") { _ in self.subtitleFontSize = 22; self.updateSubtitleLabelAppearance() },
- UIAction(title: "24") { _ in self.subtitleFontSize = 24; self.updateSubtitleLabelAppearance() },
+ UIAction(title: "16") { _ in
+ SubtitleSettingsManager.shared.update { settings in settings.fontSize = 16 }
+ self.loadSubtitleSettings()
+ self.updateSubtitleLabelAppearance()
+ },
+ UIAction(title: "18") { _ in
+ SubtitleSettingsManager.shared.update { settings in settings.fontSize = 18 }
+ self.loadSubtitleSettings()
+ self.updateSubtitleLabelAppearance()
+ },
+ UIAction(title: "20") { _ in
+ SubtitleSettingsManager.shared.update { settings in settings.fontSize = 20 }
+ self.loadSubtitleSettings()
+ self.updateSubtitleLabelAppearance()
+ },
+ UIAction(title: "22") { _ in
+ SubtitleSettingsManager.shared.update { settings in settings.fontSize = 22 }
+ self.loadSubtitleSettings()
+ self.updateSubtitleLabelAppearance()
+ },
+ UIAction(title: "24") { _ in
+ SubtitleSettingsManager.shared.update { settings in settings.fontSize = 24 }
+ self.loadSubtitleSettings()
+ self.updateSubtitleLabelAppearance()
+ },
UIAction(title: "Custom") { _ in self.presentCustomFontAlert() }
]
let fontSizeMenu = UIMenu(title: "Font Size", children: fontSizeActions)
let shadowActions = [
- UIAction(title: "None") { _ in self.subtitleShadowRadius = 0; self.updateSubtitleLabelAppearance() },
- UIAction(title: "Low") { _ in self.subtitleShadowRadius = 1; self.updateSubtitleLabelAppearance() },
- UIAction(title: "Medium") { _ in self.subtitleShadowRadius = 3; self.updateSubtitleLabelAppearance() },
- UIAction(title: "High") { _ in self.subtitleShadowRadius = 6; self.updateSubtitleLabelAppearance() }
+ UIAction(title: "None") { _ in
+ SubtitleSettingsManager.shared.update { settings in settings.shadowRadius = 0 }
+ self.loadSubtitleSettings()
+ self.updateSubtitleLabelAppearance()
+ },
+ UIAction(title: "Low") { _ in
+ SubtitleSettingsManager.shared.update { settings in settings.shadowRadius = 1 }
+ self.loadSubtitleSettings()
+ self.updateSubtitleLabelAppearance()
+ },
+ UIAction(title: "Medium") { _ in
+ SubtitleSettingsManager.shared.update { settings in settings.shadowRadius = 3 }
+ self.loadSubtitleSettings()
+ self.updateSubtitleLabelAppearance()
+ },
+ UIAction(title: "High") { _ in
+ SubtitleSettingsManager.shared.update { settings in settings.shadowRadius = 6 }
+ self.loadSubtitleSettings()
+ self.updateSubtitleLabelAppearance()
+ }
]
let shadowMenu = UIMenu(title: "Shadow Intensity", children: shadowActions)
let backgroundActions = [
UIAction(title: "Toggle") { _ in
- self.subtitleBackgroundEnabled.toggle()
+ SubtitleSettingsManager.shared.update { settings in settings.backgroundEnabled.toggle() }
+ self.loadSubtitleSettings()
self.updateSubtitleLabelAppearance()
}
]
let backgroundMenu = UIMenu(title: "Background", children: backgroundActions)
- let subtitleOptionsMenu = UIMenu(title: "Subtitle Options", children: [colorMenu, fontSizeMenu, shadowMenu, backgroundMenu])
+ let paddingActions = [
+ UIAction(title: "10p") { _ in
+ SubtitleSettingsManager.shared.update { settings in settings.bottomPadding = 10 }
+ self.loadSubtitleSettings()
+ },
+ UIAction(title: "20p") { _ in
+ SubtitleSettingsManager.shared.update { settings in settings.bottomPadding = 20 }
+ self.loadSubtitleSettings()
+ },
+ UIAction(title: "30p") { _ in
+ SubtitleSettingsManager.shared.update { settings in settings.bottomPadding = 30 }
+ self.loadSubtitleSettings()
+ },
+ UIAction(title: "Custom") { _ in self.presentCustomPaddingAlert() }
+ ]
+ let paddingMenu = UIMenu(title: "Bottom Padding", children: paddingActions)
+
+ let subtitleOptionsMenu = UIMenu(title: "Subtitle Options", children: [colorMenu, fontSizeMenu, shadowMenu, backgroundMenu, paddingMenu])
menuElements = [subtitleOptionsMenu]
}
@@ -581,6 +678,26 @@ class CustomMediaPlayerViewController: UIViewController {
return UIMenu(title: "", children: menuElements)
}
+ func presentCustomPaddingAlert() {
+ let alert = UIAlertController(title: "Enter Custom Padding", message: nil, preferredStyle: .alert)
+ alert.addTextField { textField in
+ textField.placeholder = "Padding Value"
+ textField.keyboardType = .numberPad
+ textField.text = String(Int(self.subtitleBottomPadding))
+ }
+ alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
+ alert.addAction(UIAlertAction(title: "Done", style: .default, handler: { _ in
+ if let text = alert.textFields?.first?.text, let intValue = Int(text) {
+ let newSize = CGFloat(intValue)
+ SubtitleSettingsManager.shared.update { settings in settings.bottomPadding = newSize }
+ self.loadSubtitleSettings()
+ }
+ }))
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
+ self.present(alert, animated: true, completion: nil)
+ }
+ }
+
func presentCustomFontAlert() {
let alert = UIAlertController(title: "Enter Custom Font Size", message: nil, preferredStyle: .alert)
alert.addTextField { textField in
@@ -591,7 +708,8 @@ class CustomMediaPlayerViewController: UIViewController {
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel, handler: nil))
alert.addAction(UIAlertAction(title: "Done", style: .default, handler: { _ in
if let text = alert.textFields?.first?.text, let newSize = Double(text) {
- self.subtitleFontSize = newSize
+ SubtitleSettingsManager.shared.update { settings in settings.fontSize = newSize }
+ self.loadSubtitleSettings()
self.updateSubtitleLabelAppearance()
}
}))
@@ -600,6 +718,15 @@ class CustomMediaPlayerViewController: UIViewController {
}
}
+ func loadSubtitleSettings() {
+ let settings = SubtitleSettingsManager.shared.settings
+ self.subtitleForegroundColor = settings.foregroundColor
+ self.subtitleFontSize = settings.fontSize
+ self.subtitleShadowRadius = settings.shadowRadius
+ self.subtitleBackgroundEnabled = settings.backgroundEnabled
+ self.subtitleBottomPadding = settings.bottomPadding
+ }
+
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
if UserDefaults.standard.bool(forKey: "alwaysLandscape") {
return .landscape
@@ -631,3 +758,4 @@ class CustomMediaPlayerViewController: UIViewController {
// yes? Like the plural of the famous american rapper ye? -IBHRAD
// low taper fade the meme is massive -cranci
+// cranci still doesnt have a job -seiike
diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/SubtitleSettingsManager.swift b/Sora/Utils/MediaPlayer/CustomPlayer/SubtitleSettingsManager.swift
new file mode 100644
index 0000000..cb8f82a
--- /dev/null
+++ b/Sora/Utils/MediaPlayer/CustomPlayer/SubtitleSettingsManager.swift
@@ -0,0 +1,43 @@
+//
+// SubtitleSettingsManager.swift
+// Sulfur
+//
+// Created by Francesco on 09/03/25.
+//
+
+import UIKit
+
+struct SubtitleSettings: Codable {
+ var foregroundColor: String = "white"
+ var fontSize: Double = 20.0
+ var shadowRadius: Double = 1.0
+ var backgroundEnabled: Bool = true
+ var bottomPadding: CGFloat = 20.0
+}
+
+class SubtitleSettingsManager {
+ static let shared = SubtitleSettingsManager()
+
+ private let userDefaultsKey = "SubtitleSettings"
+
+ var settings: SubtitleSettings {
+ get {
+ if let data = UserDefaults.standard.data(forKey: userDefaultsKey),
+ let savedSettings = try? JSONDecoder().decode(SubtitleSettings.self, from: data) {
+ return savedSettings
+ }
+ return SubtitleSettings()
+ }
+ set {
+ if let data = try? JSONEncoder().encode(newValue) {
+ UserDefaults.standard.set(data, forKey: userDefaultsKey)
+ }
+ }
+ }
+
+ func update(_ updateBlock: (inout SubtitleSettings) -> Void) {
+ var currentSettings = settings
+ updateBlock(¤tSettings)
+ settings = currentSettings
+ }
+}
diff --git a/Sora/Utils/MediaPlayer/VideoPlayer.swift b/Sora/Utils/MediaPlayer/VideoPlayer.swift
index 77ab5aa..3ee7d48 100644
--- a/Sora/Utils/MediaPlayer/VideoPlayer.swift
+++ b/Sora/Utils/MediaPlayer/VideoPlayer.swift
@@ -39,9 +39,8 @@ class VideoPlayerViewController: UIViewController {
}
var request = URLRequest(url: url)
- if streamUrl.contains("ascdn") {
- request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
- }
+ request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer")
+ 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 ?? [:]])
let playerItem = AVPlayerItem(asset: asset)
diff --git a/Sora/Views/DownloadView.swift b/Sora/Views/DownloadView.swift
new file mode 100644
index 0000000..8717e2a
--- /dev/null
+++ b/Sora/Views/DownloadView.swift
@@ -0,0 +1,83 @@
+//
+// DownloadView.swift
+// Sulfur
+//
+// Created by Francesco on 12/03/25.
+//
+
+import SwiftUI
+
+struct DownloadItem: Identifiable {
+ let id = UUID()
+ let title: String
+ let episode: Int
+ let type: String
+ var progress: Double
+ var status: String
+}
+
+class DownloadViewModel: ObservableObject {
+ @Published var downloads: [DownloadItem] = []
+
+ init() {
+ NotificationCenter.default.addObserver(self, selector: #selector(updateStatus(_:)), name: .DownloadManagerStatusUpdate, object: nil)
+ }
+
+ @objc func updateStatus(_ notification: Notification) {
+ guard let info = notification.userInfo,
+ let title = info["title"] as? String,
+ let episode = info["episode"] as? Int,
+ let type = info["type"] as? String,
+ let status = info["status"] as? String,
+ let progress = info["progress"] as? Double else { return }
+
+ if let index = downloads.firstIndex(where: { $0.title == title && $0.episode == episode }) {
+ downloads[index] = DownloadItem(title: title, episode: episode, type: type, progress: progress, status: status)
+ } else {
+ let newDownload = DownloadItem(title: title, episode: episode, type: type, progress: progress, status: status)
+ downloads.append(newDownload)
+ }
+ }
+}
+
+struct DownloadView: View {
+ @StateObject var viewModel = DownloadViewModel()
+
+ var body: some View {
+ NavigationView {
+ List(viewModel.downloads) { download in
+ HStack(spacing: 16) {
+ Image(systemName: iconName(for: download))
+ .resizable()
+ .frame(width: 30, height: 30)
+ .foregroundColor(.accentColor)
+
+ VStack(alignment: .leading, spacing: 4) {
+ Text("\(download.title) - Episode \(download.episode)")
+ .font(.headline)
+
+ ProgressView(value: download.progress)
+ .progressViewStyle(LinearProgressViewStyle(tint: .accentColor))
+ .frame(height: 8)
+
+ Text(download.status)
+ .font(.subheadline)
+ .foregroundColor(.secondary)
+ }
+ Spacer()
+ }
+ .padding(.vertical, 8)
+ }
+ .navigationTitle("Downloads")
+ }
+ .navigationViewStyle(StackNavigationViewStyle())
+ }
+
+ func iconName(for download: DownloadItem) -> String {
+ if download.type == "hls" {
+ return download.status.lowercased().contains("converting") ? "arrow.triangle.2.circlepath.circle.fill" : "checkmark.circle.fill"
+ } else {
+ return download.progress >= 1.0 ? "checkmark.circle.fill" : "arrow.down.circle.fill"
+ }
+ }
+}
diff --git a/Sora/Views/HomeView.swift b/Sora/Views/HomeView.swift
deleted file mode 100644
index 7d17fff..0000000
--- a/Sora/Views/HomeView.swift
+++ /dev/null
@@ -1,309 +0,0 @@
-//
-// HomeView.swift
-// Sora
-//
-// Created by Francesco on 05/01/25.
-//
-
-import SwiftUI
-import Kingfisher
-
-struct HomeView: View {
- @AppStorage("trackingService") private var tracingService: String = "AniList"
- @State private var aniListItems: [AniListItem] = []
- @State private var trendingItems: [AniListItem] = []
- @State private var continueWatchingItems: [ContinueWatchingItem] = []
-
- private var currentDeviceSeasonAndYear: (season: String, year: Int) {
- let currentDate = Date()
- let calendar = Calendar.current
- let year = calendar.component(.year, from: currentDate)
- let month = calendar.component(.month, from: currentDate)
-
- let season: String
- switch month {
- case 1...3:
- season = "Winter"
- case 4...6:
- season = "Spring"
- case 7...9:
- season = "Summer"
- default:
- season = "Fall"
- }
- return (season, year)
- }
-
- private var trendingDateString: String {
- let formatter = DateFormatter()
- formatter.dateFormat = "EEEE, dd MMMM yyyy"
- return formatter.string(from: Date())
- }
-
- var body: some View {
- NavigationView {
- VStack {
- ScrollView {
- if !continueWatchingItems.isEmpty {
- LazyVStack(alignment: .leading) {
- Text("Continue Watching")
- .font(.headline)
- .padding(.horizontal, 8)
- ScrollView(.horizontal, showsIndicators: false) {
- HStack(spacing: 8) {
- ForEach(Array(continueWatchingItems.reversed())) { item in
- Button(action: {
- if UserDefaults.standard.string(forKey: "externalPlayer") == "Sora" {
- let customMediaPlayer = CustomMediaPlayerViewController(
- module: item.module,
- urlString: item.streamUrl,
- fullUrl: item.fullUrl,
- title: item.mediaTitle,
- episodeNumber: item.episodeNumber,
- onWatchNext: { },
- subtitlesURL: item.subtitles,
- episodeImageUrl: item.imageUrl
- )
- customMediaPlayer.modalPresentationStyle = .fullScreen
-
- if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
- let rootVC = windowScene.windows.first?.rootViewController {
- findTopViewController.findViewController(rootVC).present(customMediaPlayer, animated: true, completion: nil)
- }
- } else {
- let videoPlayerViewController = VideoPlayerViewController(module: item.module)
- videoPlayerViewController.streamUrl = item.streamUrl
- videoPlayerViewController.fullUrl = item.fullUrl
- videoPlayerViewController.episodeImageUrl = item.imageUrl
- videoPlayerViewController.episodeNumber = item.episodeNumber
- videoPlayerViewController.mediaTitle = item.mediaTitle
- videoPlayerViewController.subtitles = item.subtitles ?? ""
- videoPlayerViewController.modalPresentationStyle = .fullScreen
-
- if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
- let rootVC = windowScene.windows.first?.rootViewController {
- findTopViewController.findViewController(rootVC).present(videoPlayerViewController, animated: true, completion: nil)
- }
- }
- }) {
- VStack(alignment: .leading) {
- ZStack {
- KFImage(URL(string: item.imageUrl.isEmpty ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png" : item.imageUrl))
- .placeholder {
- RoundedRectangle(cornerRadius: 10)
- .fill(Color.gray.opacity(0.3))
- .frame(width: 240, height: 135)
- .shimmering()
- }
- .setProcessor(RoundCornerImageProcessor(cornerRadius: 10))
- .resizable()
- .aspectRatio(16/9, contentMode: .fill)
- .frame(width: 240, height: 135)
- .cornerRadius(10)
- .clipped()
- .overlay(
- KFImage(URL(string: item.module.metadata.iconUrl))
- .resizable()
- .frame(width: 24, height: 24)
- .cornerRadius(4)
- .padding(4),
- alignment: .topLeading
- )
- }
- .overlay(
- ZStack {
- Rectangle()
- .fill(Color.black.opacity(0.3))
- .blur(radius: 3)
- .frame(height: 30)
-
- ProgressView(value: item.progress)
- .progressViewStyle(LinearProgressViewStyle(tint: .white))
- .padding(.horizontal, 8)
- .scaleEffect(x: 1, y: 1.5, anchor: .center)
- },
- alignment: .bottom
- )
-
- VStack(alignment: .leading) {
- Text("Episode \(item.episodeNumber)")
- .font(.caption)
- .lineLimit(1)
- .foregroundColor(.secondary)
-
- Text(item.mediaTitle)
- .font(.caption)
- .lineLimit(2)
- .foregroundColor(.primary)
- .multilineTextAlignment(.leading)
- }
- .padding(.horizontal, 8)
- }
- .frame(width: 250, height: 190)
- }
- .contextMenu {
- Button(action: { markContinueWatchingItemAsWatched(item: item) }) {
- Label("Mark as Watched", systemImage: "checkmark.circle")
- }
- Button(role: .destructive, action: { removeContinueWatchingItem(item: item) }) {
- Label("Remove Item", systemImage: "trash")
- }
- }
- }
- }
- .padding(.horizontal, 8)
- }
- .frame(height: 190)
- }
- }
- VStack(alignment: .leading, spacing: 16) {
- HStack(alignment: .bottom, spacing: 5) {
- Text("Seasonal")
- .font(.headline)
- Text("of \(currentDeviceSeasonAndYear.season) \(String(format: "%d", currentDeviceSeasonAndYear.year))")
- .font(.subheadline)
- .foregroundColor(.gray)
- }
- .padding(.horizontal, 8)
- .padding(.top, 8)
-
- ScrollView(.horizontal, showsIndicators: false) {
- HStack(spacing: 8) {
- if aniListItems.isEmpty {
- ForEach(0..<5, id: \.self) { _ in
- HomeSkeletonCell()
- }
- } else {
- ForEach(aniListItems, id: \.id) { item in
- NavigationLink(destination: AniListDetailsView(animeID: item.id)) {
- VStack {
- KFImage(URL(string: item.coverImage.large))
- .placeholder {
- RoundedRectangle(cornerRadius: 10)
- .fill(Color.gray.opacity(0.3))
- .frame(width: 130, height: 195)
- .shimmering()
- }
- .setProcessor(RoundCornerImageProcessor(cornerRadius: 10))
- .resizable()
- .scaledToFill()
- .frame(width: 130, height: 195)
- .cornerRadius(10)
- .clipped()
-
- Text(item.title.romaji)
- .font(.caption)
- .frame(width: 130)
- .lineLimit(1)
- .multilineTextAlignment(.center)
- .foregroundColor(.primary)
- }
- }
- }
- }
- }
- .padding(.horizontal, 8)
- }
-
- HStack(alignment: .bottom, spacing: 5) {
- Text("Trending")
- .font(.headline)
- Text("on \(trendingDateString)")
- .font(.subheadline)
- .foregroundColor(.gray)
- }
- .padding(.horizontal, 8)
-
- ScrollView(.horizontal, showsIndicators: false) {
- HStack(spacing: 8) {
- if trendingItems.isEmpty {
- ForEach(0..<5, id: \.self) { _ in
- HomeSkeletonCell()
- }
- } else {
- ForEach(trendingItems, id: \.id) { item in
- NavigationLink(destination: AniListDetailsView(animeID: item.id)) {
- VStack {
- KFImage(URL(string: item.coverImage.large))
- .placeholder {
- RoundedRectangle(cornerRadius: 10)
- .fill(Color.gray.opacity(0.3))
- .frame(width: 130, height: 195)
- .shimmering()
- }
- .setProcessor(RoundCornerImageProcessor(cornerRadius: 10))
- .resizable()
- .scaledToFill()
- .frame(width: 130, height: 195)
- .cornerRadius(10)
- .clipped()
-
- Text(item.title.romaji)
- .font(.caption)
- .frame(width: 130)
- .lineLimit(1)
- .multilineTextAlignment(.center)
- .foregroundColor(.primary)
- }
- }
- }
- }
- }
- .padding(.horizontal, 8)
- }
- }
- .padding(.bottom, 16)
- }
- .navigationTitle("Home")
- }
- .onAppear {
- continueWatchingItems = ContinueWatchingManager.shared.fetchItems()
- if tracingService == "TMDB" {
- TMDBSeasonal.fetchTMDBSeasonal { items in
- if let items = items {
- aniListItems = items
- }
- }
-
- TMBDTrending.fetchTMDBTrending { items in
- if let items = items {
- trendingItems = items
- }
- }
- } else {
- AnilistServiceSeasonalAnime().fetchSeasonalAnime { items in
- if let items = items {
- aniListItems = items
- }
- }
- AnilistServiceTrendingAnime().fetchTrendingAnime { items in
- if let items = items {
- trendingItems = items
- }
- }
- }
- }
- }
- .navigationViewStyle(StackNavigationViewStyle())
- }
-
- private func markContinueWatchingItemAsWatched(item: ContinueWatchingItem) {
- let key = "lastPlayedTime_\(item.fullUrl)"
- let totalKey = "totalTime_\(item.fullUrl)"
- UserDefaults.standard.set(99999999.0, forKey: key)
- UserDefaults.standard.set(99999999.0, forKey: totalKey)
- ContinueWatchingManager.shared.remove(item: item)
-
- if let index = continueWatchingItems.firstIndex(where: { $0.id == item.id }) {
- continueWatchingItems.remove(at: index)
- }
- }
-
- private func removeContinueWatchingItem(item: ContinueWatchingItem) {
- ContinueWatchingManager.shared.remove(item: item)
-
- if let index = continueWatchingItems.firstIndex(where: { $0.id == item.id }) {
- continueWatchingItems.remove(at: index)
- }
- }
-}
diff --git a/Sora/Views/LibraryView/LibraryManager.swift b/Sora/Views/LibraryView/LibraryManager.swift
index f6474d9..19712fb 100644
--- a/Sora/Views/LibraryView/LibraryManager.swift
+++ b/Sora/Views/LibraryView/LibraryManager.swift
@@ -13,14 +13,16 @@ struct LibraryItem: Codable, Identifiable {
let imageUrl: String
let href: String
let moduleId: String
+ let moduleName: String
let dateAdded: Date
- init(title: String, imageUrl: String, href: String, moduleId: String) {
+ init(title: String, imageUrl: String, href: String, moduleId: String, moduleName: String) {
self.id = UUID()
self.title = title
self.imageUrl = imageUrl
self.href = href
self.moduleId = moduleId
+ self.moduleName = moduleName
self.dateAdded = Date()
}
}
@@ -55,15 +57,15 @@ class LibraryManager: ObservableObject {
}
}
- func isBookmarked(href: String) -> Bool {
+ func isBookmarked(href: String, moduleName: String) -> Bool {
bookmarks.contains { $0.href == href }
}
- func toggleBookmark(title: String, imageUrl: String, href: String, moduleId: String) {
+ func toggleBookmark(title: String, imageUrl: String, href: String, moduleId: String, moduleName: String) {
if let index = bookmarks.firstIndex(where: { $0.href == href }) {
bookmarks.remove(at: index)
} else {
- let bookmark = LibraryItem(title: title, imageUrl: imageUrl, href: href, moduleId: moduleId)
+ let bookmark = LibraryItem(title: title, imageUrl: imageUrl, href: href, moduleId: moduleId, moduleName: moduleName)
bookmarks.insert(bookmark, at: 0)
}
saveBookmarks()
diff --git a/Sora/Views/LibraryView/LibraryView.swift b/Sora/Views/LibraryView/LibraryView.swift
index 108cde5..7bd8130 100644
--- a/Sora/Views/LibraryView/LibraryView.swift
+++ b/Sora/Views/LibraryView/LibraryView.swift
@@ -12,75 +12,256 @@ struct LibraryView: View {
@EnvironmentObject private var libraryManager: LibraryManager
@EnvironmentObject private var moduleManager: ModuleManager
+ @State private var continueWatchingItems: [ContinueWatchingItem] = []
+
private let columns = [
- GridItem(.adaptive(minimum: 150), spacing: 16)
+ GridItem(.adaptive(minimum: 150), spacing: 12)
]
var body: some View {
NavigationView {
ScrollView {
- if libraryManager.bookmarks.isEmpty {
- VStack(spacing: 8) {
- Image(systemName: "magazine")
- .font(.largeTitle)
- .foregroundColor(.secondary)
- Text("No Items saved")
- .font(.headline)
- Text("You can bookmark items to find them easily here")
- .font(.caption)
- .foregroundColor(.secondary)
+ VStack(alignment: .leading, spacing: 12) {
+ Text("Continue Watching")
+ .font(.title2)
+ .bold()
+ .padding(.horizontal, 20)
+
+ if continueWatchingItems.isEmpty {
+ VStack(spacing: 8) {
+ Image(systemName: "play.circle")
+ .font(.largeTitle)
+ .foregroundColor(.secondary)
+ Text("No items to continue watching.")
+ .font(.headline)
+ Text("Recently watched content will appear here.")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ .padding()
+ .frame(maxWidth: .infinity)
+ } else {
+ ContinueWatchingSection(items: $continueWatchingItems, markAsWatched: { item in
+ markContinueWatchingItemAsWatched(item: item)
+ }, removeItem: { item in
+ removeContinueWatchingItem(item: item)
+ })
}
- .padding()
- .frame(maxWidth: .infinity)
- } else {
- LazyVGrid(columns: columns, spacing: 16) {
- ForEach(libraryManager.bookmarks) { item in
- if let module = moduleManager.modules.first(where: { $0.id.uuidString == item.moduleId }) {
- NavigationLink(destination: MediaInfoView(title: item.title, imageUrl: item.imageUrl, href: item.href, module: module)) {
- VStack {
- ZStack(alignment: .bottomTrailing) {
- KFImage(URL(string: item.imageUrl))
- .placeholder {
- RoundedRectangle(cornerRadius: 10)
- .fill(Color.gray.opacity(0.3))
- .frame(width: 150, height: 225)
- .shimmering()
- }
- .resizable()
- .aspectRatio(2/3, contentMode: .fill)
- .cornerRadius(10)
- .frame(width: 150, height: 225)
+
+ Text("Bookmarks")
+ .font(.title2)
+ .bold()
+ .padding(.horizontal, 20)
+
+ if libraryManager.bookmarks.isEmpty {
+ VStack(spacing: 8) {
+ Image(systemName: "magazine")
+ .font(.largeTitle)
+ .foregroundColor(.secondary)
+ Text("You have no items saved.")
+ .font(.headline)
+ Text("Bookmark items for an easier access later.")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+ .padding()
+ .frame(maxWidth: .infinity)
+ } else {
+ LazyVGrid(columns: columns, spacing: 12) {
+ ForEach(libraryManager.bookmarks) { item in
+ if let module = moduleManager.modules.first(where: { $0.id.uuidString == item.moduleId }) {
+ NavigationLink(destination: MediaInfoView(title: item.title, imageUrl: item.imageUrl, href: item.href, module: module)) {
+ VStack {
+ ZStack {
+ KFImage(URL(string: item.imageUrl))
+ .placeholder {
+ RoundedRectangle(cornerRadius: 10)
+ .fill(Color.gray.opacity(0.3))
+ .frame(width: 150, height: 225)
+ .shimmering()
+ }
+ .resizable()
+ .aspectRatio(2/3, contentMode: .fill)
+ .frame(width: 150, height: 225)
+ .cornerRadius(10)
+ .clipped()
+ .overlay(
+ KFImage(URL(string: module.metadata.iconUrl))
+ .resizable()
+ .frame(width: 24, height: 24)
+ .cornerRadius(4)
+ .padding(4),
+ alignment: .topLeading
+ )
+ }
- KFImage(URL(string: module.metadata.iconUrl))
- .placeholder {
- Circle()
- .fill(Color.gray.opacity(0.3))
- .frame(width: 35, height: 35)
- .shimmering()
- }
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: 35, height: 35)
- .clipShape(Circle())
- .padding(5)
+ Text(item.title)
+ .font(.subheadline)
+ .foregroundColor(.primary)
+ .lineLimit(2)
+ .multilineTextAlignment(.leading)
}
-
- Text(item.title)
- .font(.subheadline)
- .foregroundColor(.primary)
- .lineLimit(2)
- .multilineTextAlignment(.leading)
- .padding(.horizontal, 8)
}
}
}
}
+ .padding(.horizontal, 20)
}
- .padding()
}
+ .padding(.vertical, 20)
}
.navigationTitle("Library")
+ .onAppear {
+ fetchContinueWatching()
+ }
}
.navigationViewStyle(StackNavigationViewStyle())
}
+
+ private func fetchContinueWatching() {
+ continueWatchingItems = ContinueWatchingManager.shared.fetchItems()
+ }
+
+ private func markContinueWatchingItemAsWatched(item: ContinueWatchingItem) {
+ let key = "lastPlayedTime_\(item.fullUrl)"
+ let totalKey = "totalTime_\(item.fullUrl)"
+ UserDefaults.standard.set(99999999.0, forKey: key)
+ UserDefaults.standard.set(99999999.0, forKey: totalKey)
+ ContinueWatchingManager.shared.remove(item: item)
+ continueWatchingItems.removeAll { $0.id == item.id }
+ }
+
+ private func removeContinueWatchingItem(item: ContinueWatchingItem) {
+ ContinueWatchingManager.shared.remove(item: item)
+ continueWatchingItems.removeAll { $0.id == item.id }
+ }
+}
+
+struct ContinueWatchingSection: View {
+ @Binding var items: [ContinueWatchingItem]
+ var markAsWatched: (ContinueWatchingItem) -> Void
+ var removeItem: (ContinueWatchingItem) -> Void
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ ForEach(Array(items.reversed())) { item in
+ ContinueWatchingCell(item: item,markAsWatched: {
+ markAsWatched(item)
+ }, removeItem: {
+ removeItem(item)
+ })
+ }
+ }
+ .padding(.horizontal, 20)
+ }
+ .frame(height: 190)
+ }
+ }
+}
+
+struct ContinueWatchingCell: View {
+ let item: ContinueWatchingItem
+ var markAsWatched: () -> Void
+ var removeItem: () -> Void
+
+ var body: some View {
+ Button(action: {
+ if UserDefaults.standard.string(forKey: "externalPlayer") == "Default" {
+ let videoPlayerViewController = VideoPlayerViewController(module: item.module)
+ videoPlayerViewController.streamUrl = item.streamUrl
+ videoPlayerViewController.fullUrl = item.fullUrl
+ videoPlayerViewController.episodeImageUrl = item.imageUrl
+ videoPlayerViewController.episodeNumber = item.episodeNumber
+ videoPlayerViewController.mediaTitle = item.mediaTitle
+ videoPlayerViewController.subtitles = item.subtitles ?? ""
+ videoPlayerViewController.modalPresentationStyle = .fullScreen
+
+ if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+ let rootVC = windowScene.windows.first?.rootViewController {
+ findTopViewController.findViewController(rootVC).present(videoPlayerViewController, animated: true, completion: nil)
+ }
+ } else {
+ let customMediaPlayer = CustomMediaPlayerViewController(
+ module: item.module,
+ urlString: item.streamUrl,
+ fullUrl: item.fullUrl,
+ title: item.mediaTitle,
+ episodeNumber: item.episodeNumber,
+ onWatchNext: { },
+ subtitlesURL: item.subtitles,
+ episodeImageUrl: item.imageUrl
+ )
+ customMediaPlayer.modalPresentationStyle = .fullScreen
+
+ if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
+ let rootVC = windowScene.windows.first?.rootViewController {
+ findTopViewController.findViewController(rootVC).present(customMediaPlayer, animated: true, completion: nil)
+ }
+ }
+ }) {
+ VStack(alignment: .leading) {
+ ZStack {
+ KFImage(URL(string: item.imageUrl.isEmpty ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/main/assets/banner2.png" : item.imageUrl))
+ .placeholder {
+ RoundedRectangle(cornerRadius: 10)
+ .fill(Color.gray.opacity(0.3))
+ .frame(width: 240, height: 135)
+ .shimmering()
+ }
+ .setProcessor(RoundCornerImageProcessor(cornerRadius: 10))
+ .resizable()
+ .aspectRatio(16/9, contentMode: .fill)
+ .frame(width: 240, height: 135)
+ .cornerRadius(10)
+ .clipped()
+ .overlay(
+ KFImage(URL(string: item.module.metadata.iconUrl))
+ .resizable()
+ .frame(width: 24, height: 24)
+ .cornerRadius(4)
+ .padding(4),
+ alignment: .topLeading
+ )
+ }
+ .overlay(
+ ZStack {
+ Rectangle()
+ .fill(Color.black.opacity(0.3))
+ .blur(radius: 3)
+ .frame(height: 30)
+
+ ProgressView(value: item.progress)
+ .progressViewStyle(LinearProgressViewStyle(tint: .white))
+ .padding(.horizontal, 8)
+ .scaleEffect(x: 1, y: 1.5, anchor: .center)
+ },
+ alignment: .bottom
+ )
+
+ VStack(alignment: .leading) {
+ Text("Episode \(item.episodeNumber)")
+ .font(.caption)
+ .lineLimit(1)
+ .foregroundColor(.secondary)
+
+ Text(item.mediaTitle)
+ .font(.caption)
+ .lineLimit(2)
+ .foregroundColor(.primary)
+ .multilineTextAlignment(.leading)
+ }
+ }
+ .frame(width: 240, height: 170)
+ }
+ .contextMenu {
+ Button(action: { markAsWatched() }) {
+ Label("Mark as Watched", systemImage: "checkmark.circle")
+ }
+ Button(role: .destructive, action: { removeItem() }) {
+ Label("Remove Item", systemImage: "trash")
+ }
+ }
+ }
}
diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift
index 908acc7..b284a47 100644
--- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift
+++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift
@@ -15,34 +15,19 @@ struct EpisodeLink: Identifiable {
}
struct EpisodeCell: View {
+ let episodeIndex: Int
let episode: String
let episodeID: Int
let progress: Double
let itemID: Int
+ let onTap: (String) -> Void
+ let onMarkAllPrevious: () -> Void
+
@State private var episodeTitle: String = ""
@State private var episodeImageUrl: String = ""
@State private var isLoading: Bool = true
@State private var currentProgress: Double = 0.0
- let onTap: (String) -> Void
-
- private func markAsWatched() {
- UserDefaults.standard.set(99999999.0, forKey: "lastPlayedTime_\(episode)")
- UserDefaults.standard.set(99999999.0, forKey: "totalTime_\(episode)")
- updateProgress()
- }
-
- private func resetProgress() {
- UserDefaults.standard.set(0.0, forKey: "lastPlayedTime_\(episode)")
- UserDefaults.standard.set(0.0, forKey: "totalTime_\(episode)")
- updateProgress()
- }
-
- private func updateProgress() {
- let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(episode)")
- let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(episode)")
- currentProgress = totalTime > 0 ? lastPlayedTime / totalTime : 0
- }
var body: some View {
HStack {
@@ -76,31 +61,55 @@ struct EpisodeCell: View {
}
.contentShape(Rectangle())
.contextMenu {
- if currentProgress <= 0.9 {
+ if progress <= 0.9 {
Button(action: markAsWatched) {
Label("Mark as Watched", systemImage: "checkmark.circle")
}
}
- if currentProgress != 0 {
+ if progress != 0 {
Button(action: resetProgress) {
Label("Reset Progress", systemImage: "arrow.counterclockwise")
}
}
+
+ if episodeIndex > 0 {
+ Button(action: onMarkAllPrevious) {
+ Label("Mark All Previous Watched", systemImage: "checkmark.circle.fill")
+ }
+ }
}
.onAppear {
- if UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil ||
- UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata") {
+ if UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil
+ || UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata") {
fetchEpisodeDetails()
}
- updateProgress()
+ currentProgress = progress
}
.onTapGesture {
onTap(episodeImageUrl)
}
}
- func fetchEpisodeDetails() {
+ private func markAsWatched() {
+ UserDefaults.standard.set(99999999.0, forKey: "lastPlayedTime_\(episode)")
+ UserDefaults.standard.set(99999999.0, forKey: "totalTime_\(episode)")
+ updateProgress()
+ }
+
+ private func resetProgress() {
+ UserDefaults.standard.set(0.0, forKey: "lastPlayedTime_\(episode)")
+ UserDefaults.standard.set(0.0, forKey: "totalTime_\(episode)")
+ updateProgress()
+ }
+
+ private func updateProgress() {
+ let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(episode)")
+ let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(episode)")
+ currentProgress = totalTime > 0 ? lastPlayedTime / totalTime : 0
+ }
+
+ private func fetchEpisodeDetails() {
guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(itemID)") else {
isLoading = false
return
diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift
index 24ac21a..8b47f57 100644
--- a/Sora/Views/MediaInfoView/MediaInfoView.swift
+++ b/Sora/Views/MediaInfoView/MediaInfoView.swift
@@ -34,6 +34,8 @@ struct MediaInfoView: View {
@State var isRefetching: Bool = true
@State var isFetchingEpisode: Bool = false
+ @State private var refreshTrigger: Bool = false
+
@State private var selectedEpisodeNumber: Int = 0
@State private var selectedEpisodeImage: String = ""
@@ -167,10 +169,11 @@ struct MediaInfoView: View {
title: title,
imageUrl: imageUrl,
href: href,
- moduleId: module.id.uuidString
+ moduleId: module.id.uuidString,
+ moduleName: module.metadata.sourceName
)
}) {
- Image(systemName: libraryManager.isBookmarked(href: href) ? "bookmark.fill" : "bookmark")
+ Image(systemName: libraryManager.isBookmarked(href: href, moduleName: module.metadata.sourceName) ? "bookmark.fill" : "bookmark")
.resizable()
.frame(width: 20, height: 27)
.foregroundColor(Color.accentColor)
@@ -209,15 +212,34 @@ struct MediaInfoView: View {
let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)")
let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0
- EpisodeCell(episode: ep.href, episodeID: ep.number - 1, progress: progress, itemID: itemID ?? 0, onTap: { imageUrl in
+ 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])
+ AnalyticsManager.shared.sendEvent(
+ event: "watch",
+ additionalData: ["title": title, "episode": ep.number]
+ )
}
+ },
+ onMarkAllPrevious: {
+ for idx in 0..