diff --git a/Sora/ContentView.swift b/Sora/ContentView.swift index cf1b64f..afbd4ea 100644 --- a/Sora/ContentView.swift +++ b/Sora/ContentView.swift @@ -15,10 +15,6 @@ struct ContentView: View { .tabItem { Label("Library", systemImage: "books.vertical") } - DownloadView() - .tabItem { - Label("Download", systemImage: "arrow.down.to.line.circle") - } SearchView() .tabItem { Label("Search", systemImage: "magnifyingglass") diff --git a/Sora/Utils/ContinueWatching/DownloadManager.swift b/Sora/Utils/ContinueWatching/DownloadManager.swift new file mode 100644 index 0000000..9c3d90d --- /dev/null +++ b/Sora/Utils/ContinueWatching/DownloadManager.swift @@ -0,0 +1,95 @@ +// +// DownloadManager.swift +// Sulfur +// +// Created by Francesco on 29/04/25. +// + +import SwiftUI +import AVKit +import AVFoundation + +class DownloadManager: NSObject, ObservableObject { + @Published var activeDownloads: [(URL, Double)] = [] + @Published var localPlaybackURL: URL? + + private var assetDownloadURLSession: AVAssetDownloadURLSession! + private var activeDownloadTasks: [URLSessionTask: URL] = [:] + + override init() { + super.init() + initializeDownloadSession() + loadLocalContent() + } + + private func initializeDownloadSession() { + let configuration = URLSessionConfiguration.background(withIdentifier: "hls-downloader") + assetDownloadURLSession = AVAssetDownloadURLSession( + configuration: configuration, + assetDownloadDelegate: self, + delegateQueue: .main + ) + } + + func downloadAsset(from url: URL) { + let asset = AVURLAsset(url: url) + let task = assetDownloadURLSession.makeAssetDownloadTask( + asset: asset, + assetTitle: "Offline Video", + assetArtworkData: nil, + options: nil + ) + + task?.resume() + activeDownloadTasks[task!] = url + } + + private func loadLocalContent() { + guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } + + do { + let contents = try FileManager.default.contentsOfDirectory( + at: documents, + includingPropertiesForKeys: nil, + options: .skipsHiddenFiles + ) + + if let localURL = contents.first(where: { $0.pathExtension == "movpkg" }) { + localPlaybackURL = localURL + } + } catch { + print("Error loading local content: \(error)") + } + } +} + +extension DownloadManager: AVAssetDownloadDelegate { + func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) { + activeDownloadTasks.removeValue(forKey: assetDownloadTask) + localPlaybackURL = location + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + guard let error = error else { return } + print("Download error: \(error.localizedDescription)") + activeDownloadTasks.removeValue(forKey: task) + } + + func urlSession(_ session: URLSession, + assetDownloadTask: AVAssetDownloadTask, + didLoad timeRange: CMTimeRange, + totalTimeRangesLoaded loadedTimeRanges: [NSValue], + timeRangeExpectedToLoad: CMTimeRange) { + + guard let url = activeDownloadTasks[assetDownloadTask] else { return } + let progress = loadedTimeRanges + .map { $0.timeRangeValue.duration.seconds / timeRangeExpectedToLoad.duration.seconds } + .reduce(0, +) + + if let index = activeDownloads.firstIndex(where: { $0.0 == url }) { + activeDownloads[index].1 = progress + } else { + activeDownloads.append((url, progress)) + } + } +} diff --git a/Sora/Utils/DownloadManager/DownloadManager.swift b/Sora/Utils/DownloadManager/DownloadManager.swift deleted file mode 100644 index 7c975df..0000000 --- a/Sora/Utils/DownloadManager/DownloadManager.swift +++ /dev/null @@ -1,209 +0,0 @@ -// -// DownloadManager.swift -// Sulfur -// -// Created by Francesco on 29/04/25. -// - -import SwiftUI -import AVKit -import AVFoundation - -class DownloadManager: NSObject, ObservableObject { - @Published var activeDownloads: [ActiveDownload] = [] - @Published var savedAssets: [DownloadedAsset] = [] - - private var assetDownloadURLSession: AVAssetDownloadURLSession! - private var activeDownloadTasks: [URLSessionTask: URL] = [:] - - override init() { - super.init() - initializeDownloadSession() - loadSavedAssets() - reconcileFileSystemAssets() - } - - private func initializeDownloadSession() { - let configuration = URLSessionConfiguration.background(withIdentifier: "hls-downloader-\(UUID().uuidString)") - assetDownloadURLSession = AVAssetDownloadURLSession( - configuration: configuration, - assetDownloadDelegate: self, - delegateQueue: .main - ) - } - - func downloadAsset(from url: URL, module: ScrapingModule) { - guard !savedAssets.contains(where: { $0.originalURL == url }) else { return } - - var urlRequest = URLRequest(url: url) - urlRequest.addValue(module.metadata.baseUrl, forHTTPHeaderField: "Origin") - urlRequest.addValue(module.metadata.baseUrl, forHTTPHeaderField: "Referer") - - let asset = AVURLAsset(url: urlRequest.url!, options: ["AVURLAssetHTTPHeaderFieldsKey": urlRequest.allHTTPHeaderFields ?? [:]]) - - let task = assetDownloadURLSession.makeAssetDownloadTask( - asset: asset, - assetTitle: url.lastPathComponent, - assetArtworkData: nil, - options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 2_000_000] - ) - - let download = ActiveDownload( - id: UUID(), - originalURL: url, - progress: 0, - task: task! - ) - - activeDownloads.append(download) - activeDownloadTasks[task!] = url - task?.resume() - } - - func deleteAsset(_ asset: DownloadedAsset) { - do { - try FileManager.default.removeItem(at: asset.localURL) - savedAssets.removeAll { $0.id == asset.id } - saveAssets() - } catch { - print("Error deleting asset: \(error)") - } - } - - func renameAsset(_ asset: DownloadedAsset, newName: String) { - guard let index = savedAssets.firstIndex(where: { $0.id == asset.id }) else { return } - savedAssets[index].name = newName - saveAssets() - } - - private func saveAssets() { - do { - let data = try JSONEncoder().encode(savedAssets) - UserDefaults.standard.set(data, forKey: "savedAssets") - } catch { - print("Error saving assets: \(error)") - } - } - - private func loadSavedAssets() { - guard let data = UserDefaults.standard.data(forKey: "savedAssets") else { return } - do { - savedAssets = try JSONDecoder().decode([DownloadedAsset].self, from: data) - } catch { - print("Error loading saved assets: \(error)") - } - } - - private func reconcileFileSystemAssets() { - guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return } - - do { - let fileURLs = try FileManager.default.contentsOfDirectory( - at: documents, - includingPropertiesForKeys: [.creationDateKey, .fileSizeKey], - options: .skipsHiddenFiles - ) - - for url in fileURLs where url.pathExtension == "movpkg" { - if !savedAssets.contains(where: { $0.localURL == url }) { - let newAsset = DownloadedAsset( - name: url.deletingPathExtension().lastPathComponent, - downloadDate: Date(), - originalURL: url, - localURL: url - ) - savedAssets.append(newAsset) - } - } - saveAssets() - } catch { - print("Error reconciling files: \(error)") - } - } -} - -extension DownloadManager: AVAssetDownloadDelegate { - func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) { - guard let originalURL = activeDownloadTasks[assetDownloadTask] else { return } - - let newAsset = DownloadedAsset( - name: originalURL.lastPathComponent, - downloadDate: Date(), - originalURL: originalURL, - localURL: location - ) - - savedAssets.append(newAsset) - saveAssets() - cleanupDownloadTask(assetDownloadTask) - } - - func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { - guard let error = error else { return } - print("Download error: \(error.localizedDescription)") - cleanupDownloadTask(task) - } - - func urlSession(_ session: URLSession, - assetDownloadTask: AVAssetDownloadTask, - didLoad timeRange: CMTimeRange, - totalTimeRangesLoaded loadedTimeRanges: [NSValue], - timeRangeExpectedToLoad: CMTimeRange) { - guard let originalURL = activeDownloadTasks[assetDownloadTask], - let downloadIndex = activeDownloads.firstIndex(where: { $0.originalURL == originalURL }) else { return } - - let progress = loadedTimeRanges - .map { $0.timeRangeValue.duration.seconds / timeRangeExpectedToLoad.duration.seconds } - .reduce(0, +) - - activeDownloads[downloadIndex].progress = progress - } - - private func cleanupDownloadTask(_ task: URLSessionTask) { - activeDownloadTasks.removeValue(forKey: task) - activeDownloads.removeAll { $0.task == task } - } -} - -struct DownloadProgressView: View { - let download: ActiveDownload - - var body: some View { - VStack(alignment: .leading) { - Text(download.originalURL.lastPathComponent) - .font(.subheadline) - ProgressView(value: download.progress) - .progressViewStyle(LinearProgressViewStyle()) - Text("\(Int(download.progress * 100))%") - .font(.caption) - } - } -} - -struct AssetRowView: View { - let asset: DownloadedAsset - - var body: some View { - VStack(alignment: .leading) { - Text(asset.name) - .font(.headline) - Text("\(asset.fileSize ?? 0) bytes • \(asset.downloadDate.formatted())") - .font(.caption) - .foregroundColor(.secondary) - } - } -} - -struct ActiveDownload: Identifiable { - let id: UUID - let originalURL: URL - var progress: Double - let task: URLSessionTask -} - -extension URL { - static func isValidHLSURL(string: String) -> Bool { - guard let url = URL(string: string), url.pathExtension == "m3u8" else { return false } - return true - } -} diff --git a/Sora/Views/DownloadView.swift b/Sora/Views/DownloadView.swift index 15ee58f..d98a28a 100644 --- a/Sora/Views/DownloadView.swift +++ b/Sora/Views/DownloadView.swift @@ -8,120 +8,40 @@ import SwiftUI import AVKit -struct DownloadedAsset: Identifiable, Codable { - let id: UUID - var name: String - let downloadDate: Date - let originalURL: URL - let localURL: URL - var fileSize: Int64? - - init(id: UUID = UUID(), name: String, downloadDate: Date, originalURL: URL, localURL: URL) { - self.id = id - self.name = name - self.downloadDate = downloadDate - self.originalURL = originalURL - self.localURL = localURL - self.fileSize = getFileSize() - } - - func getFileSize() -> Int64? { - do { - let values = try localURL.resourceValues(forKeys: [.fileSizeKey]) - return Int64(values.fileSize ?? 0) - } catch { - return nil - } - } -} - struct DownloadView: View { @StateObject private var viewModel = DownloadManager() - @State private var showingDeleteAlert = false - @State private var assetToDelete: DownloadedAsset? - @State private var renameText = "" - @State private var assetToRename: DownloadedAsset? + @State private var hlsURL = "https://test-streams.mux.dev/x36xhzz/url_6/193039199_mp4_h264_aac_hq_7.m3u8" var body: some View { NavigationView { - List { - if !viewModel.activeDownloads.isEmpty { - Section("Active Downloads") { - ForEach(viewModel.activeDownloads) { download in - DownloadProgressView(download: download) - } + VStack { + TextField("Enter HLS URL", text: $hlsURL) + .textFieldStyle(RoundedBorderTextFieldStyle()) + .padding() + + Button("Download Stream") { + viewModel.downloadAsset(from: URL(string: hlsURL)!) + } + .padding() + + List(viewModel.activeDownloads, id: \.0) { (url, progress) in + VStack(alignment: .leading) { + Text(url.absoluteString) + ProgressView(value: progress) + .progressViewStyle(LinearProgressViewStyle()) } } - if !viewModel.savedAssets.isEmpty { - Section("Completed Downloads") { - ForEach(viewModel.savedAssets) { asset in - NavigationLink { - VideoPlayer(player: AVPlayer(url: asset.localURL)) - .navigationTitle(asset.name) - } label: { - AssetRowView(asset: asset) - } - .contextMenu { - Button(action: { startRenaming(asset) }) { - Label("Rename", systemImage: "pencil") - } - - Button(role: .destructive, action: { confirmDelete(asset) }) { - Label("Delete", systemImage: "trash") - } - } - } - } - } - - if viewModel.activeDownloads.isEmpty && viewModel.savedAssets.isEmpty { - Section { - Text("No downloads") - .foregroundColor(.secondary) - .frame(maxWidth: .infinity, alignment: .center) - .listRowBackground(Color.clear) + NavigationLink("Play Offline Content") { + if let url = viewModel.localPlaybackURL { + VideoPlayer(player: AVPlayer(url: url)) + } else { + Text("No offline content available") } } + .padding() } - .navigationTitle("Downloads") - .alert("Delete Download", isPresented: $showingDeleteAlert) { - Button("Cancel", role: .cancel) { } - Button("Delete", role: .destructive) { - if let asset = assetToDelete { - viewModel.deleteAsset(asset) - } - } - } message: { - Text("Are you sure you want to delete \(assetToDelete?.name ?? "this download")?") - } - .alert("Rename Download", isPresented: Binding( - get: { assetToRename != nil }, - set: { if !$0 { assetToRename = nil } } - )) { - TextField("Name", text: $renameText) - Button("Cancel", role: .cancel) { - renameText = "" - assetToRename = nil - } - Button("Save") { - if let asset = assetToRename { - viewModel.renameAsset(asset, newName: renameText) - } - renameText = "" - assetToRename = nil - } - } + .navigationTitle("HLS Downloader") } } - - private func confirmDelete(_ asset: DownloadedAsset) { - assetToDelete = asset - showingDeleteAlert = true - } - - private func startRenaming(_ asset: DownloadedAsset) { - assetToRename = asset - renameText = asset.name - } -} \ No newline at end of file +} diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index a203ac2..bf41b8b 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -23,7 +23,6 @@ struct EpisodeCell: View { let onTap: (String) -> Void let onMarkAllPrevious: () -> Void - let onDownload: () -> Void @State private var episodeTitle: String = "" @State private var episodeImageUrl: String = "" @@ -36,12 +35,12 @@ struct EpisodeCell: View { var defaultBannerImage: String { let isLightMode = selectedAppearance == .light || (selectedAppearance == .system && colorScheme == .light) return isLightMode - ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png" - : "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner2.png" + ? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png" + : "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner2.png" } init(episodeIndex: Int, episode: String, episodeID: Int, progress: Double, - itemID: Int, onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void, onDownload: @escaping () -> Void) { // Add the new parameter + itemID: Int, onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void) { self.episodeIndex = episodeIndex self.episode = episode self.episodeID = episodeID @@ -49,7 +48,6 @@ struct EpisodeCell: View { self.itemID = itemID self.onTap = onTap self.onMarkAllPrevious = onMarkAllPrevious - self.onDownload = onDownload } var body: some View { @@ -101,10 +99,6 @@ struct EpisodeCell: View { Label("Mark All Previous Watched", systemImage: "checkmark.circle.fill") } } - - Button(action: onDownload) { - Label("Download Episode", systemImage: "arrow.down.circle") - } } .onAppear { updateProgress() diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 6fc281d..6dec97e 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -59,8 +59,7 @@ struct MediaInfoView: View { @Environment(\.dismiss) private var dismiss @State private var orientationChanged: Bool = false - @StateObject private var downloadManager = DownloadManager() - + private var isGroupedBySeasons: Bool { return groupedEpisodes().count > 1 } @@ -330,9 +329,6 @@ struct MediaInfoView: View { refreshTrigger.toggle() Logger.shared.log("Marked episodes watched within season \(selectedSeason + 1) of \"\(title)\".", type: "General") - }, - onDownload: { - self.downloadEpisode(href: ep.href, episodeNumber: ep.number) } ) .id(refreshTrigger) @@ -383,9 +379,6 @@ struct MediaInfoView: View { refreshTrigger.toggle() Logger.shared.log("Marked \(ep.number - 1) episodes watched within series \"\(title)\".", type: "General") - }, - onDownload: { - self.downloadEpisode(href: ep.href, episodeNumber: ep.number) } ) .id(refreshTrigger) @@ -1038,72 +1031,4 @@ struct MediaInfoView: View { findTopViewController.findViewController(rootVC).present(alert, animated: true) } } - - private func downloadEpisode(href: String, episodeNumber: Int) { - let downloadID = UUID() - activeFetchID = downloadID - currentStreamTitle = "Episode \(episodeNumber)" - showStreamLoadingView = true - - Task { - do { - let jsContent = try moduleManager.getModuleContent(module) - jsController.loadScript(jsContent) - - jsController.fetchStreamUrl(episodeUrl: href, module: module) { result in - guard self.activeFetchID == downloadID else { return } - - if let streams = result.streams, !streams.isEmpty { - // If there are multiple streams, show selection dialog - if streams.count > 1 { - DispatchQueue.main.async { - self.showDownloadStreamSelection(streams: streams) - } - } else { - // If there's only one stream, download it directly - if let url = URL(string: streams[0]) { - DispatchQueue.main.async { - self.downloadManager.downloadAsset(from: url, module: self.module) - } - } - } - } - - DispatchQueue.main.async { - self.showStreamLoadingView = false - self.activeFetchID = nil - } - } - } catch { - DispatchQueue.main.async { - self.showStreamLoadingView = false - self.activeFetchID = nil - Logger.shared.log("Download error: \(error)", type: "Error") - } - } - } - } - - private func showDownloadStreamSelection(streams: [String]) { - let alert = UIAlertController(title: "Select Stream to Download", - message: "Choose a stream quality", - preferredStyle: .actionSheet) - - for (index, stream) in streams.enumerated() { - let action = UIAlertAction(title: "Stream \(index + 1)", style: .default) { _ in - if let url = URL(string: stream) { - self.downloadManager.downloadAsset(from: url, module: self.module) - } - } - alert.addAction(action) - } - - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) - - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let rootVC = window.rootViewController { - findTopViewController.findViewController(rootVC).present(alert, animated: true) - } - } } diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 2a8e32a..4415b7b 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -176,7 +176,6 @@ 131270152DC139CD0093AA9C /* DownloadManager */ = { isa = PBXGroup; children = ( - 131270162DC13A010093AA9C /* DownloadManager.swift */, ); path = DownloadManager; sourceTree = ""; @@ -375,6 +374,7 @@ children = ( 13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */, 13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */, + 131270162DC13A010093AA9C /* DownloadManager.swift */, ); path = ContinueWatching; sourceTree = "";