mirror of
https://github.com/cranci1/Sora.git
synced 2026-04-21 08:32:00 +00:00
This commit is contained in:
parent
d57b803129
commit
72fce2f14b
7 changed files with 412 additions and 133 deletions
|
|
@ -15,6 +15,10 @@ struct ContentView: View {
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Library", systemImage: "books.vertical")
|
Label("Library", systemImage: "books.vertical")
|
||||||
}
|
}
|
||||||
|
DownloadView()
|
||||||
|
.tabItem {
|
||||||
|
Label("Download", systemImage: "arrow.down.to.line.circle")
|
||||||
|
}
|
||||||
SearchView()
|
SearchView()
|
||||||
.tabItem {
|
.tabItem {
|
||||||
Label("Search", systemImage: "magnifyingglass")
|
Label("Search", systemImage: "magnifyingglass")
|
||||||
|
|
|
||||||
|
|
@ -1,95 +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: [(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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
209
Sora/Utils/DownloadManager/DownloadManager.swift
Normal file
209
Sora/Utils/DownloadManager/DownloadManager.swift
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,40 +8,120 @@
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import AVKit
|
import AVKit
|
||||||
|
|
||||||
struct DownloadView: View {
|
struct DownloadedAsset: Identifiable, Codable {
|
||||||
@StateObject private var viewModel = DownloadManager()
|
let id: UUID
|
||||||
@State private var hlsURL = "https://test-streams.mux.dev/x36xhzz/url_6/193039199_mp4_h264_aac_hq_7.m3u8"
|
var name: String
|
||||||
|
let downloadDate: Date
|
||||||
|
let originalURL: URL
|
||||||
|
let localURL: URL
|
||||||
|
var fileSize: Int64?
|
||||||
|
|
||||||
var body: some View {
|
init(id: UUID = UUID(), name: String, downloadDate: Date, originalURL: URL, localURL: URL) {
|
||||||
NavigationView {
|
self.id = id
|
||||||
VStack {
|
self.name = name
|
||||||
TextField("Enter HLS URL", text: $hlsURL)
|
self.downloadDate = downloadDate
|
||||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
self.originalURL = originalURL
|
||||||
.padding()
|
self.localURL = localURL
|
||||||
|
self.fileSize = getFileSize()
|
||||||
Button("Download Stream") {
|
}
|
||||||
viewModel.downloadAsset(from: URL(string: hlsURL)!)
|
|
||||||
}
|
func getFileSize() -> Int64? {
|
||||||
.padding()
|
do {
|
||||||
|
let values = try localURL.resourceValues(forKeys: [.fileSizeKey])
|
||||||
List(viewModel.activeDownloads, id: \.0) { (url, progress) in
|
return Int64(values.fileSize ?? 0)
|
||||||
VStack(alignment: .leading) {
|
} catch {
|
||||||
Text(url.absoluteString)
|
return nil
|
||||||
ProgressView(value: progress)
|
|
||||||
.progressViewStyle(LinearProgressViewStyle())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
NavigationLink("Play Offline Content") {
|
|
||||||
if let url = viewModel.localPlaybackURL {
|
|
||||||
VideoPlayer(player: AVPlayer(url: url))
|
|
||||||
} else {
|
|
||||||
Text("No offline content available")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding()
|
|
||||||
}
|
|
||||||
.navigationTitle("HLS Downloader")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
NavigationView {
|
||||||
|
List {
|
||||||
|
if !viewModel.activeDownloads.isEmpty {
|
||||||
|
Section("Active Downloads") {
|
||||||
|
ForEach(viewModel.activeDownloads) { download in
|
||||||
|
DownloadProgressView(download: download)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func confirmDelete(_ asset: DownloadedAsset) {
|
||||||
|
assetToDelete = asset
|
||||||
|
showingDeleteAlert = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startRenaming(_ asset: DownloadedAsset) {
|
||||||
|
assetToRename = asset
|
||||||
|
renameText = asset.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,7 @@ struct EpisodeCell: View {
|
||||||
|
|
||||||
let onTap: (String) -> Void
|
let onTap: (String) -> Void
|
||||||
let onMarkAllPrevious: () -> Void
|
let onMarkAllPrevious: () -> Void
|
||||||
|
let onDownload: () -> Void
|
||||||
|
|
||||||
@State private var episodeTitle: String = ""
|
@State private var episodeTitle: String = ""
|
||||||
@State private var episodeImageUrl: String = ""
|
@State private var episodeImageUrl: String = ""
|
||||||
|
|
@ -35,12 +36,12 @@ struct EpisodeCell: View {
|
||||||
var defaultBannerImage: String {
|
var defaultBannerImage: String {
|
||||||
let isLightMode = selectedAppearance == .light || (selectedAppearance == .system && colorScheme == .light)
|
let isLightMode = selectedAppearance == .light || (selectedAppearance == .system && colorScheme == .light)
|
||||||
return isLightMode
|
return isLightMode
|
||||||
? "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.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"
|
: "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner2.png"
|
||||||
}
|
}
|
||||||
|
|
||||||
init(episodeIndex: Int, episode: String, episodeID: Int, progress: Double,
|
init(episodeIndex: Int, episode: String, episodeID: Int, progress: Double,
|
||||||
itemID: Int, onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void) {
|
itemID: Int, onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void, onDownload: @escaping () -> Void) { // Add the new parameter
|
||||||
self.episodeIndex = episodeIndex
|
self.episodeIndex = episodeIndex
|
||||||
self.episode = episode
|
self.episode = episode
|
||||||
self.episodeID = episodeID
|
self.episodeID = episodeID
|
||||||
|
|
@ -48,6 +49,7 @@ struct EpisodeCell: View {
|
||||||
self.itemID = itemID
|
self.itemID = itemID
|
||||||
self.onTap = onTap
|
self.onTap = onTap
|
||||||
self.onMarkAllPrevious = onMarkAllPrevious
|
self.onMarkAllPrevious = onMarkAllPrevious
|
||||||
|
self.onDownload = onDownload
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
@ -99,6 +101,10 @@ struct EpisodeCell: View {
|
||||||
Label("Mark All Previous Watched", systemImage: "checkmark.circle.fill")
|
Label("Mark All Previous Watched", systemImage: "checkmark.circle.fill")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button(action: onDownload) {
|
||||||
|
Label("Download Episode", systemImage: "arrow.down.circle")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
updateProgress()
|
updateProgress()
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,8 @@ struct MediaInfoView: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
@State private var orientationChanged: Bool = false
|
@State private var orientationChanged: Bool = false
|
||||||
|
@StateObject private var downloadManager = DownloadManager()
|
||||||
|
|
||||||
private var isGroupedBySeasons: Bool {
|
private var isGroupedBySeasons: Bool {
|
||||||
return groupedEpisodes().count > 1
|
return groupedEpisodes().count > 1
|
||||||
}
|
}
|
||||||
|
|
@ -329,6 +330,9 @@ struct MediaInfoView: View {
|
||||||
|
|
||||||
refreshTrigger.toggle()
|
refreshTrigger.toggle()
|
||||||
Logger.shared.log("Marked episodes watched within season \(selectedSeason + 1) of \"\(title)\".", type: "General")
|
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)
|
.id(refreshTrigger)
|
||||||
|
|
@ -379,6 +383,9 @@ struct MediaInfoView: View {
|
||||||
|
|
||||||
refreshTrigger.toggle()
|
refreshTrigger.toggle()
|
||||||
Logger.shared.log("Marked \(ep.number - 1) episodes watched within series \"\(title)\".", type: "General")
|
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)
|
.id(refreshTrigger)
|
||||||
|
|
@ -1031,4 +1038,72 @@ struct MediaInfoView: View {
|
||||||
findTopViewController.findViewController(rootVC).present(alert, animated: true)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,7 @@
|
||||||
131270152DC139CD0093AA9C /* DownloadManager */ = {
|
131270152DC139CD0093AA9C /* DownloadManager */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
131270162DC13A010093AA9C /* DownloadManager.swift */,
|
||||||
);
|
);
|
||||||
path = DownloadManager;
|
path = DownloadManager;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
@ -374,7 +375,6 @@
|
||||||
children = (
|
children = (
|
||||||
13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */,
|
13C0E5E92D5F85EA00E7F619 /* ContinueWatchingManager.swift */,
|
||||||
13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */,
|
13C0E5EB2D5F85F800E7F619 /* ContinueWatchingItem.swift */,
|
||||||
131270162DC13A010093AA9C /* DownloadManager.swift */,
|
|
||||||
);
|
);
|
||||||
path = ContinueWatching;
|
path = ContinueWatching;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue