mirror of
https://github.com/cranci1/Sora.git
synced 2026-03-11 17:45:37 +00:00
Added downlaods
This commit is contained in:
parent
e1a671390c
commit
e875fead9b
11 changed files with 465 additions and 119 deletions
|
|
@ -6,7 +6,6 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import Kingfisher
|
||||
|
||||
struct ContentView: View {
|
||||
var body: some View {
|
||||
|
|
@ -15,6 +14,10 @@ struct ContentView: View {
|
|||
.tabItem {
|
||||
Label("Library", systemImage: "books.vertical")
|
||||
}
|
||||
DownloadView()
|
||||
.tabItem {
|
||||
Label("Downloads", systemImage: "arrow.down.app.fill")
|
||||
}
|
||||
SearchView()
|
||||
.tabItem {
|
||||
Label("Search", systemImage: "magnifyingglass")
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ struct SoraApp: App {
|
|||
@StateObject private var settings = Settings()
|
||||
@StateObject private var moduleManager = ModuleManager()
|
||||
@StateObject private var librarykManager = LibraryManager()
|
||||
|
||||
@StateObject private var downloadManager = DownloadManager()
|
||||
|
||||
init() {
|
||||
if let userAccentColor = UserDefaults.standard.color(forKey: "accentColor") {
|
||||
UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = userAccentColor
|
||||
|
|
@ -26,13 +27,14 @@ struct SoraApp: App {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.environmentObject(moduleManager)
|
||||
.environmentObject(settings)
|
||||
.environmentObject(librarykManager)
|
||||
.environmentObject(downloadManager)
|
||||
.accentColor(settings.accentColor)
|
||||
.onAppear {
|
||||
settings.updateAppearance()
|
||||
|
|
@ -51,7 +53,7 @@ struct SoraApp: App {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func handleURL(_ url: URL) {
|
||||
guard url.scheme == "sora", let host = url.host else { return }
|
||||
switch host {
|
||||
|
|
@ -61,9 +63,8 @@ struct SoraApp: App {
|
|||
|
||||
UserDefaults.standard.set(libraryURL, forKey: "lastCommunityURL")
|
||||
UserDefaults.standard.set(true, forKey: "didReceiveDefaultPageLink")
|
||||
|
||||
let communityView = CommunityLibraryView()
|
||||
.environmentObject(moduleManager)
|
||||
|
||||
let communityView = CommunityLibraryView().environmentObject(moduleManager)
|
||||
let hostingController = UIHostingController(rootView: communityView)
|
||||
DispatchQueue.main.async {
|
||||
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
|
|
@ -89,11 +90,10 @@ struct SoraApp: App {
|
|||
else {
|
||||
return
|
||||
}
|
||||
|
||||
let addModuleView = ModuleAdditionSettingsView(moduleUrl: moduleURL)
|
||||
.environmentObject(moduleManager)
|
||||
|
||||
let addModuleView = ModuleAdditionSettingsView(moduleUrl: moduleURL).environmentObject(moduleManager)
|
||||
let hostingController = UIHostingController(rootView: addModuleView)
|
||||
|
||||
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let window = windowScene.windows.first {
|
||||
window.rootViewController?.present(hostingController, animated: true)
|
||||
|
|
@ -103,19 +103,19 @@ struct SoraApp: App {
|
|||
type: "Error"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static func handleRedirect(url: URL) {
|
||||
guard let params = url.queryParameters,
|
||||
let code = params["code"] else {
|
||||
Logger.shared.log("Failed to extract authorization code")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.shared.log("Failed to extract authorization code")
|
||||
return
|
||||
}
|
||||
|
||||
switch url.host {
|
||||
case "anilist":
|
||||
AniListToken.exchangeAuthorizationCodeForToken(code: code) { success in
|
||||
|
|
@ -125,7 +125,7 @@ struct SoraApp: App {
|
|||
Logger.shared.log("AniList token exchange failed", type: "Error")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
case "trakt":
|
||||
TraktToken.exchangeAuthorizationCodeForToken(code: code) { success in
|
||||
if success {
|
||||
|
|
@ -134,7 +134,7 @@ struct SoraApp: App {
|
|||
Logger.shared.log("Trakt token exchange failed", type: "Error")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
default:
|
||||
Logger.shared.log("Unknown authentication service", type: "Error")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,23 +7,54 @@
|
|||
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
|
||||
struct DownloadedAsset: Identifiable, Codable {
|
||||
let id: UUID
|
||||
var name: String
|
||||
let downloadDate: Date
|
||||
let originalURL: URL
|
||||
let localURL: URL
|
||||
var fileSize: Int64?
|
||||
let module: ScrapingModule
|
||||
|
||||
init(id: UUID = UUID(), name: String, downloadDate: Date, originalURL: URL, localURL: URL, module: ScrapingModule) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.downloadDate = downloadDate
|
||||
self.originalURL = originalURL
|
||||
self.localURL = localURL
|
||||
self.module = module
|
||||
self.fileSize = getFileSize()
|
||||
}
|
||||
|
||||
func getFileSize() -> Int64? {
|
||||
do {
|
||||
let values = try localURL.resourceValues(forKeys: [.fileSizeKey])
|
||||
return Int64(values.fileSize ?? 0)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class DownloadManager: NSObject, ObservableObject {
|
||||
@Published var activeDownloads: [(URL, Double)] = []
|
||||
@Published var localPlaybackURL: URL?
|
||||
@Published var activeDownloads: [ActiveDownload] = []
|
||||
@Published var savedAssets: [DownloadedAsset] = []
|
||||
|
||||
private var assetDownloadURLSession: AVAssetDownloadURLSession!
|
||||
private var activeDownloadTasks: [URLSessionTask: URL] = [:]
|
||||
private var activeDownloadTasks: [URLSessionTask: (URL, ScrapingModule)] = [:]
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
initializeDownloadSession()
|
||||
loadLocalContent()
|
||||
loadSavedAssets()
|
||||
reconcileFileSystemAssets()
|
||||
}
|
||||
|
||||
private func initializeDownloadSession() {
|
||||
let configuration = URLSessionConfiguration.background(withIdentifier: "hls-downloader")
|
||||
let configuration = URLSessionConfiguration.background(withIdentifier: "downloader-\(UUID().uuidString)")
|
||||
assetDownloadURLSession = AVAssetDownloadURLSession(
|
||||
configuration: configuration,
|
||||
assetDownloadDelegate: self,
|
||||
|
|
@ -31,65 +62,170 @@ class DownloadManager: NSObject, ObservableObject {
|
|||
)
|
||||
}
|
||||
|
||||
func downloadAsset(from url: URL) {
|
||||
let asset = AVURLAsset(url: url)
|
||||
func downloadAsset(from url: URL, module: ScrapingModule, headers: [String: String]? = nil) {
|
||||
var urlRequest = URLRequest(url: url)
|
||||
|
||||
|
||||
if let headers = headers {
|
||||
for (key, value) in headers {
|
||||
urlRequest.addValue(value, forHTTPHeaderField: key)
|
||||
}
|
||||
} else if headers == nil {
|
||||
urlRequest.addValue(module.metadata.baseUrl, forHTTPHeaderField: "Origin")
|
||||
urlRequest.addValue(module.metadata.baseUrl, forHTTPHeaderField: "Referer")
|
||||
}
|
||||
|
||||
urlRequest.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent")
|
||||
|
||||
let asset = AVURLAsset(url: urlRequest.url!, options: ["AVURLAssetHTTPHeaderFieldsKey": urlRequest.allHTTPHeaderFields ?? [:]])
|
||||
|
||||
let task = assetDownloadURLSession.makeAssetDownloadTask(
|
||||
asset: asset,
|
||||
assetTitle: "Offline Video",
|
||||
assetTitle: url.lastPathComponent,
|
||||
assetArtworkData: nil,
|
||||
options: nil
|
||||
options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 2_000_000]
|
||||
)
|
||||
|
||||
|
||||
let download = ActiveDownload(
|
||||
id: UUID(),
|
||||
originalURL: url,
|
||||
progress: 0,
|
||||
task: task!
|
||||
)
|
||||
|
||||
activeDownloads.append(download)
|
||||
activeDownloadTasks[task!] = (url, module)
|
||||
task?.resume()
|
||||
activeDownloadTasks[task!] = url
|
||||
}
|
||||
|
||||
private func loadLocalContent() {
|
||||
func deleteAsset(_ asset: DownloadedAsset) {
|
||||
do {
|
||||
try FileManager.default.removeItem(at: asset.localURL)
|
||||
savedAssets.removeAll { $0.id == asset.id }
|
||||
saveAssets()
|
||||
} catch {
|
||||
Logger.shared.log("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 {
|
||||
Logger.shared.log("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 {
|
||||
Logger.shared.log("Error loading saved assets: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func reconcileFileSystemAssets() {
|
||||
guard let documents = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
|
||||
|
||||
do {
|
||||
let contents = try FileManager.default.contentsOfDirectory(
|
||||
let fileURLs = try FileManager.default.contentsOfDirectory(
|
||||
at: documents,
|
||||
includingPropertiesForKeys: nil,
|
||||
includingPropertiesForKeys: [.creationDateKey, .fileSizeKey],
|
||||
options: .skipsHiddenFiles
|
||||
)
|
||||
|
||||
if let localURL = contents.first(where: { $0.pathExtension == "movpkg" }) {
|
||||
localPlaybackURL = localURL
|
||||
}
|
||||
} catch {
|
||||
print("Error loading local content: \(error)")
|
||||
Logger.shared.log("Error reconciling files: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension DownloadManager: AVAssetDownloadDelegate {
|
||||
func urlSession(_ session: URLSession, assetDownloadTask: AVAssetDownloadTask, didFinishDownloadingTo location: URL) {
|
||||
activeDownloadTasks.removeValue(forKey: assetDownloadTask)
|
||||
localPlaybackURL = location
|
||||
guard let (originalURL, module) = activeDownloadTasks[assetDownloadTask] else { return }
|
||||
|
||||
let newAsset = DownloadedAsset(
|
||||
name: originalURL.lastPathComponent,
|
||||
downloadDate: Date(),
|
||||
originalURL: originalURL,
|
||||
localURL: location,
|
||||
module: module
|
||||
)
|
||||
|
||||
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)")
|
||||
activeDownloadTasks.removeValue(forKey: task)
|
||||
Logger.shared.log("Download error: \(error.localizedDescription)")
|
||||
cleanupDownloadTask(task)
|
||||
}
|
||||
|
||||
func urlSession(_ session: URLSession,
|
||||
assetDownloadTask: AVAssetDownloadTask,
|
||||
didLoad timeRange: CMTimeRange,
|
||||
totalTimeRangesLoaded loadedTimeRanges: [NSValue],
|
||||
timeRangeExpectedToLoad: CMTimeRange) {
|
||||
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 }
|
||||
|
||||
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))
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,22 +6,22 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
// URL DELEGATE CLASS FOR FETCH API
|
||||
|
||||
class FetchDelegate: NSObject, URLSessionTaskDelegate
|
||||
{
|
||||
private let allowRedirects: Bool
|
||||
init(allowRedirects: Bool) {
|
||||
self.allowRedirects = allowRedirects
|
||||
}
|
||||
// This handles the redirection and prevents it.
|
||||
|
||||
func urlSession(_ session: URLSession, task: URLSessionTask, willPerformHTTPRedirection response: HTTPURLResponse, newRequest request: URLRequest, completionHandler: @escaping (URLRequest?) -> Void) {
|
||||
if(allowRedirects)
|
||||
{
|
||||
completionHandler(request) // Allow Redirect
|
||||
completionHandler(request)
|
||||
}
|
||||
else
|
||||
{
|
||||
completionHandler(nil) // Block Redirect
|
||||
completionHandler(nil)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -63,7 +63,7 @@ extension URLSession {
|
|||
configuration.httpAdditionalHeaders = ["User-Agent": randomUserAgent]
|
||||
return URLSession(configuration: configuration)
|
||||
}()
|
||||
// return url session that redirects based on input
|
||||
|
||||
static func fetchData(allowRedirects:Bool) -> URLSession
|
||||
{
|
||||
let delegate = FetchDelegate(allowRedirects:allowRedirects)
|
||||
|
|
@ -72,4 +72,3 @@ extension URLSession {
|
|||
return URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ extension JSController {
|
|||
if let data = resultString.data(using: .utf8) {
|
||||
do {
|
||||
if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
|
||||
print("JSON DATA IS \(json) 2")
|
||||
var streamUrls: [String]? = nil
|
||||
var subtitleUrls: [String]? = nil
|
||||
var streamUrlsAndHeaders : [[String:Any]]? = nil
|
||||
|
|
@ -117,7 +116,6 @@ extension JSController {
|
|||
let data = jsonString.data(using: .utf8) {
|
||||
do {
|
||||
if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
|
||||
print("JSON object is \(json) 1")
|
||||
var streamUrls: [String]? = nil
|
||||
var subtitleUrls: [String]? = nil
|
||||
var streamUrlsAndHeaders : [[String:Any]]? = nil
|
||||
|
|
@ -227,7 +225,6 @@ extension JSController {
|
|||
let data = jsonString.data(using: .utf8) {
|
||||
do {
|
||||
if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
|
||||
print("JSON object is \(json) 3 ")
|
||||
var streamUrls: [String]? = nil
|
||||
var subtitleUrls: [String]? = nil
|
||||
var streamUrlsAndHeaders : [[String:Any]]? = nil
|
||||
|
|
|
|||
|
|
@ -301,9 +301,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele
|
|||
}
|
||||
|
||||
#if os(iOS) && !targetEnvironment(macCatalyst)
|
||||
if #available(iOS 16.0, *) {
|
||||
playerViewController.allowsVideoFrameAnalysis = false
|
||||
}
|
||||
#endif
|
||||
|
||||
if let url = subtitlesURL, !url.isEmpty {
|
||||
|
|
|
|||
|
|
@ -5,43 +5,206 @@
|
|||
// Created by Francesco on 29/04/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
import SwiftUI
|
||||
|
||||
struct EmptyStateView: View {
|
||||
let title: String
|
||||
let systemImage: String
|
||||
let description: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.system(size: 50))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(title)
|
||||
.font(.title2)
|
||||
.fontWeight(.medium)
|
||||
|
||||
Text(description)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
struct DownloadView: View {
|
||||
@StateObject private var viewModel = DownloadManager()
|
||||
@State private var hlsURL = "https://test-streams.mux.dev/x36xhzz/url_6/193039199_mp4_h264_aac_hq_7.m3u8"
|
||||
@EnvironmentObject private var downloadManager: DownloadManager
|
||||
@State private var selectedAsset: DownloadedAsset?
|
||||
@State private var showingDeleteAlert = false
|
||||
@State private var showingRenameAlert = false
|
||||
@State private var renameText = ""
|
||||
@State private var selectedSegment = 0
|
||||
@State private var player: AVPlayer?
|
||||
|
||||
@AppStorage("defaultPlayer") private var defaultPlayer: String = "Default"
|
||||
|
||||
enum Tab: String, CaseIterable {
|
||||
case active = "Active"
|
||||
case completed = "Completed"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
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())
|
||||
Picker("Downloads", selection: $selectedSegment) {
|
||||
ForEach(0..<Tab.allCases.count, id: \.self) { index in
|
||||
Text(Tab.allCases[index].rawValue)
|
||||
}
|
||||
}
|
||||
.pickerStyle(SegmentedPickerStyle())
|
||||
.padding()
|
||||
|
||||
NavigationLink("Play Offline Content") {
|
||||
if let url = viewModel.localPlaybackURL {
|
||||
VideoPlayer(player: AVPlayer(url: url))
|
||||
if selectedSegment == 0 {
|
||||
if downloadManager.activeDownloads.isEmpty {
|
||||
EmptyStateView(
|
||||
title: "No Active Downloads",
|
||||
systemImage: "arrow.down.circle",
|
||||
description: "Downloads in progress will appear here"
|
||||
)
|
||||
} else {
|
||||
Text("No offline content available")
|
||||
List {
|
||||
ForEach(downloadManager.activeDownloads) { download in
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(download.originalURL.lastPathComponent)
|
||||
.font(.subheadline)
|
||||
.lineLimit(1)
|
||||
|
||||
ProgressView(value: download.progress) {
|
||||
Text("\(Int(download.progress * 100))%")
|
||||
.font(.caption2)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if downloadManager.savedAssets.isEmpty {
|
||||
EmptyStateView(
|
||||
title: "No Completed Downloads",
|
||||
systemImage: "arrow.down.circle",
|
||||
description: "Completed Downloads will appear here"
|
||||
)
|
||||
} else {
|
||||
List {
|
||||
ForEach(downloadManager.savedAssets) { asset in
|
||||
Button(action: { playAsset(asset) }) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(asset.name)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
|
||||
if let size = asset.fileSize {
|
||||
Text("\(formatFileSize(size)) • \(formatDate(asset.downloadDate))")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "play.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button(action: {
|
||||
renameText = asset.name
|
||||
selectedAsset = asset
|
||||
showingRenameAlert = true
|
||||
}) {
|
||||
Label("Rename", systemImage: "pencil")
|
||||
}
|
||||
|
||||
Button(role: .destructive, action: {
|
||||
selectedAsset = asset
|
||||
showingDeleteAlert = true
|
||||
}) {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.navigationTitle("HLS Downloader")
|
||||
.navigationTitle("Downloads")
|
||||
.alert("Delete Download", isPresented: $showingDeleteAlert) {
|
||||
Button("Cancel", role: .cancel) { }
|
||||
Button("Delete", role: .destructive) {
|
||||
if let asset = selectedAsset {
|
||||
downloadManager.deleteAsset(asset)
|
||||
selectedAsset = nil
|
||||
}
|
||||
}
|
||||
} message: {
|
||||
Text("Are you sure you want to delete '\(selectedAsset?.name ?? "")'?")
|
||||
}
|
||||
.alert("Rename Download", isPresented: $showingRenameAlert) {
|
||||
TextField("Name", text: $renameText)
|
||||
Button("Cancel", role: .cancel) {
|
||||
showingRenameAlert = false
|
||||
}
|
||||
Button("Save") {
|
||||
if let asset = selectedAsset {
|
||||
downloadManager.renameAsset(asset, newName: renameText)
|
||||
}
|
||||
showingRenameAlert = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
private func playAsset(_ asset: DownloadedAsset) {
|
||||
if defaultPlayer == "Default" {
|
||||
let playerVC = VideoPlayerViewController(module: asset.module)
|
||||
playerVC.streamUrl = asset.localURL.absoluteString
|
||||
playerVC.fullUrl = asset.originalURL.absoluteString
|
||||
playerVC.modalPresentationStyle = .fullScreen
|
||||
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let rootVC = windowScene.windows.first?.rootViewController {
|
||||
findTopViewController.findViewController(rootVC).present(playerVC, animated: true, completion: nil)
|
||||
}
|
||||
} else {
|
||||
let playerVC = CustomMediaPlayerViewController(
|
||||
module: asset.module,
|
||||
urlString: asset.localURL.absoluteString,
|
||||
fullUrl: asset.originalURL.absoluteString,
|
||||
title: asset.name,
|
||||
episodeNumber: 1,
|
||||
onWatchNext: {},
|
||||
subtitlesURL: nil,
|
||||
aniListID: 0,
|
||||
episodeImageUrl: "",
|
||||
headers: nil
|
||||
)
|
||||
playerVC.modalPresentationStyle = .fullScreen
|
||||
|
||||
if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let rootVC = windowScene.windows.first?.rootViewController {
|
||||
findTopViewController.findViewController(rootVC).present(playerVC, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatFileSize(_ size: Int64) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.allowedUnits = [.useKB, .useMB, .useGB]
|
||||
formatter.countStyle = .file
|
||||
return formatter.string(fromByteCount: size)
|
||||
}
|
||||
|
||||
private func formatDate(_ date: Date) -> String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .short
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ struct EpisodeCell: View {
|
|||
let episodeID: Int
|
||||
let progress: Double
|
||||
let itemID: Int
|
||||
let module: ScrapingModule
|
||||
|
||||
let onTap: (String) -> Void
|
||||
let onMarkAllPrevious: () -> Void
|
||||
|
|
@ -28,6 +29,11 @@ struct EpisodeCell: View {
|
|||
@State private var episodeImageUrl: String = ""
|
||||
@State private var isLoading: Bool = true
|
||||
@State private var currentProgress: Double = 0.0
|
||||
@State private var isFetchingEpisode: Bool = false
|
||||
|
||||
@StateObject private var jsController = JSController()
|
||||
@EnvironmentObject private var moduleManager: ModuleManager
|
||||
@EnvironmentObject private var downloadManager: DownloadManager
|
||||
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@AppStorage("selectedAppearance") private var selectedAppearance: Appearance = .system
|
||||
|
|
@ -35,17 +41,19 @@ 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) {
|
||||
itemID: Int, module: ScrapingModule, onTap: @escaping (String) -> Void,
|
||||
onMarkAllPrevious: @escaping () -> Void) {
|
||||
self.episodeIndex = episodeIndex
|
||||
self.episode = episode
|
||||
self.episodeID = episodeID
|
||||
self.progress = progress
|
||||
self.itemID = itemID
|
||||
self.module = module
|
||||
self.onTap = onTap
|
||||
self.onMarkAllPrevious = onMarkAllPrevious
|
||||
}
|
||||
|
|
@ -99,6 +107,10 @@ struct EpisodeCell: View {
|
|||
Label("Mark All Previous Watched", systemImage: "checkmark.circle.fill")
|
||||
}
|
||||
}
|
||||
|
||||
Button(action: downloadEpisode) {
|
||||
Label("Download Episode", systemImage: "arrow.down.circle")
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
updateProgress()
|
||||
|
|
@ -187,4 +199,70 @@ struct EpisodeCell: View {
|
|||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private func downloadEpisode() {
|
||||
isFetchingEpisode = true
|
||||
|
||||
Task {
|
||||
do {
|
||||
let jsContent = try moduleManager.getModuleContent(module)
|
||||
jsController.loadScript(jsContent)
|
||||
|
||||
if module.metadata.asyncJS == true {
|
||||
jsController.fetchStreamUrlJS(episodeUrl: episode, softsub: module.metadata.softsub == true, module: module) { result in
|
||||
if let sources = result.sources, !sources.isEmpty {
|
||||
let streamUrl = sources[0]["streamUrl"] as? String ?? ""
|
||||
let headers = sources[0]["headers"] as? [String: String] ?? [:]
|
||||
self.startDownload(url: streamUrl, headers: headers)
|
||||
}
|
||||
else if let streams = result.streams, !streams.isEmpty {
|
||||
self.startDownload(url: streams[0])
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.isFetchingEpisode = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
jsController.fetchStreamUrl(episodeUrl: episode, softsub: module.metadata.softsub == true, module: module) { result in
|
||||
if let sources = result.sources, !sources.isEmpty {
|
||||
let streamUrl = sources[0]["streamUrl"] as? String ?? ""
|
||||
let headers = sources[0]["headers"] as? [String: String] ?? [:]
|
||||
self.startDownload(url: streamUrl, headers: headers)
|
||||
}
|
||||
else if let streams = result.streams, !streams.isEmpty {
|
||||
self.startDownload(url: streams[0])
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
self.isFetchingEpisode = false
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Logger.shared.log("Error starting download: \(error)", type: "Error")
|
||||
DispatchQueue.main.async {
|
||||
self.isFetchingEpisode = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startDownload(url: String, headers: [String: String]? = nil) {
|
||||
guard let streamUrl = URL(string: url) else {
|
||||
Logger.shared.log("Invalid stream URL for download", type: "Error")
|
||||
return
|
||||
}
|
||||
|
||||
downloadManager.downloadAsset(
|
||||
from: streamUrl,
|
||||
module: module,
|
||||
headers: headers
|
||||
)
|
||||
|
||||
DropManager.shared.showDrop(
|
||||
title: "Download Started",
|
||||
subtitle: "Episode \(episodeID + 1)",
|
||||
duration: 1.0,
|
||||
icon: UIImage(systemName: "arrow.down.circle.fill")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -300,6 +300,7 @@ struct MediaInfoView: View {
|
|||
episodeID: ep.number - 1,
|
||||
progress: progress,
|
||||
itemID: itemID ?? 0,
|
||||
module: module,
|
||||
onTap: { imageUrl in
|
||||
if !isFetchingEpisode {
|
||||
selectedEpisodeNumber = ep.number
|
||||
|
|
@ -350,6 +351,7 @@ struct MediaInfoView: View {
|
|||
episodeID: ep.number - 1,
|
||||
progress: progress,
|
||||
itemID: itemID ?? 0,
|
||||
module: module,
|
||||
onTap: { imageUrl in
|
||||
if !isFetchingEpisode {
|
||||
selectedEpisodeNumber = ep.number
|
||||
|
|
|
|||
|
|
@ -38,14 +38,6 @@ struct SearchView: View {
|
|||
return moduleManager.modules.first { $0.id.uuidString == id }
|
||||
}
|
||||
|
||||
private var loadingMessages: [String] = [
|
||||
"Searching the depths...",
|
||||
"Looking for results...",
|
||||
"Fetching data...",
|
||||
"Please wait...",
|
||||
"Almost there..."
|
||||
]
|
||||
|
||||
private var columnsCount: Int {
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
let isLandscape = UIScreen.main.bounds.width > UIScreen.main.bounds.height
|
||||
|
|
|
|||
|
|
@ -74,27 +74,6 @@ struct SettingsViewPlayer: View {
|
|||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Progress bar Marker Color")) {
|
||||
ColorPicker("Segments Color", selection: Binding(
|
||||
get: {
|
||||
if let data = UserDefaults.standard.data(forKey: "segmentsColorData"),
|
||||
let uiColor = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? UIColor {
|
||||
return Color(uiColor)
|
||||
}
|
||||
return .yellow
|
||||
},
|
||||
set: { newColor in
|
||||
let uiColor = UIColor(newColor)
|
||||
if let data = try? NSKeyedArchiver.archivedData(
|
||||
withRootObject: uiColor,
|
||||
requiringSecureCoding: false
|
||||
) {
|
||||
UserDefaults.standard.set(data, forKey: "segmentsColorData")
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
|
||||
Section(header: Text("Skip Settings"), footer : Text("Double tapping the screen on it's sides will skip with the short tap setting.")) {
|
||||
HStack {
|
||||
Text("Tap Skip:")
|
||||
|
|
|
|||
Loading…
Reference in a new issue