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