Debrid: Add loading indicator and fix iOS <14.5 issues

When a search result is selected, there is usually a delay due to
the debrid dance of API routes for grabbing a download link to stream.
Add a loading indicator and prevent any other tasks from loading
unless the user cancels it.

iOS 14.5 was a huge update which added many QoL SwiftUI changes that
are consistent to modern iOS versions.

However, Ferrite supports iOS versions less than 14.5, mainly 14.3.
More fixes had to be added to make sure UI is consistent across
all OS versions.

Signed-off-by: kingbri <bdashore3@gmail.com>
This commit is contained in:
kingbri 2022-08-28 13:23:24 -04:00 committed by kingbri
parent 49010a270e
commit 1761f8dfb4
21 changed files with 376 additions and 233 deletions

View file

@ -65,6 +65,7 @@
0CA148E9288903F000DE2211 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D1288903F000DE2211 /* MainView.swift */; };
0CA148EB288903F000DE2211 /* SearchResultsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D3288903F000DE2211 /* SearchResultsView.swift */; };
0CA148EC288903F000DE2211 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA148D4288903F000DE2211 /* ContentView.swift */; };
0CA3FB2028B91D9500FA10A8 /* IndeterminateProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */; };
0CAF1C7B286F5C8600296F86 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 0CAF1C7A286F5C8600296F86 /* SwiftSoup */; };
0CBC76FD288D914F0054BE44 /* BatchChoiceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */; };
0CBC76FF288DAAD00054BE44 /* NavigationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */; };
@ -126,6 +127,7 @@
0CA148D1288903F000DE2211 /* MainView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
0CA148D3288903F000DE2211 /* SearchResultsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchResultsView.swift; sourceTree = "<group>"; };
0CA148D4288903F000DE2211 /* ContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IndeterminateProgressView.swift; sourceTree = "<group>"; };
0CAF1C68286F5C0E00296F86 /* Ferrite.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ferrite.app; sourceTree = BUILT_PRODUCTS_DIR; };
0CBC76FC288D914F0054BE44 /* BatchChoiceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchChoiceView.swift; sourceTree = "<group>"; };
0CBC76FE288DAAD00054BE44 /* NavigationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationViewModel.swift; sourceTree = "<group>"; };
@ -228,6 +230,7 @@
0CFEFCFC288A006200B3F490 /* GroupBoxStyle.swift */,
0C32FB562890D1F2002BD219 /* ListRowViews.swift */,
0C7D11FB28AA01E900ED92DB /* DynamicAccentColor.swift */,
0CA3FB1F28B91D9500FA10A8 /* IndeterminateProgressView.swift */,
);
path = CommonViews;
sourceTree = "<group>";
@ -421,6 +424,7 @@
0C84F4832895BFED0074B7C9 /* Source+CoreDataProperties.swift in Sources */,
0CA148DB288903F000DE2211 /* NavView.swift in Sources */,
0C750745289B003E004B3906 /* SourceRssParser+CoreDataProperties.swift in Sources */,
0CA3FB2028B91D9500FA10A8 /* IndeterminateProgressView.swift in Sources */,
0CBC7705288DE7F40054BE44 /* PersistenceController.swift in Sources */,
0C31133D28B1ABFA004DCB0D /* SourceJsonParser+CoreDataProperties.swift in Sources */,
0CA0545B288EEA4E00850554 /* SourceListEditorView.swift in Sources */,

View file

@ -18,9 +18,7 @@ public enum RealDebridError: Error {
case AuthQuery(description: String)
}
public class RealDebrid: ObservableObject {
var parentManager: DebridManager?
public class RealDebrid {
let jsonDecoder = JSONDecoder()
let keychain = KeychainSwift()
@ -31,7 +29,7 @@ public class RealDebrid: ObservableObject {
var authTask: Task<Void, Error>?
// Fetches the device code from RD
public func getVerificationInfo() async throws -> String {
public func getVerificationInfo() async throws -> DeviceCodeResponse {
var urlComponents = URLComponents(string: "\(baseAuthUrl)/device/code")!
urlComponents.queryItems = [
URLQueryItem(name: "client_id", value: openSourceClientId),
@ -47,22 +45,7 @@ public class RealDebrid: ObservableObject {
let (data, _) = try await URLSession.shared.data(for: request)
let rawResponse = try jsonDecoder.decode(DeviceCodeResponse.self, from: data)
// Spawn a separate process to get the device code
Task {
do {
try await getDeviceCredentials(deviceCode: rawResponse.deviceCode)
} catch {
print("Authentication error in \(#function): \(error)")
authTask?.cancel()
Task { @MainActor in
parentManager?.toastModel?.toastDescription = "Authentication error in \(#function): \(error)"
}
}
}
return rawResponse.directVerificationURL
return rawResponse
} catch {
print("Couldn't get the new client creds!")
throw RealDebridError.AuthQuery(description: error.localizedDescription)
@ -82,32 +65,36 @@ public class RealDebrid: ObservableObject {
}
let request = URLRequest(url: url)
try await getDeviceCredentialsInternal(urlRequest: request, deviceCode: deviceCode)
}
// Timer to poll RD api for credentials
func getDeviceCredentialsInternal(urlRequest: URLRequest, deviceCode: String) async throws {
// Timer to poll RD API for credentials
authTask = Task {
var count = 0
while count < 20 {
let (data, _) = try await URLSession.shared.data(for: urlRequest)
if Task.isCancelled {
throw RealDebridError.AuthQuery(description: "Token request cancelled.")
}
let (data, _) = try await URLSession.shared.data(for: request)
// We don't care if this fails
let rawResponse = try? self.jsonDecoder.decode(DeviceCredentialsResponse.self, from: data)
// If there's a client ID from the response, end the task successfully
if let clientId = rawResponse?.clientID, let clientSecret = rawResponse?.clientSecret {
UserDefaults.standard.set(clientId, forKey: "RealDebrid.ClientId")
keychain.set(clientSecret, forKey: "RealDebrid.ClientSecret")
try await getTokens(deviceCode: deviceCode)
break
return
} else {
try await Task.sleep(seconds: 5)
count += 1
}
}
throw RealDebridError.AuthQuery(description: "Could not fetch the client ID and secret in time. Try logging in again.")
}
if case let .failure(error) = await authTask?.result {
@ -148,11 +135,6 @@ public class RealDebrid: ObservableObject {
let accessTimestamp = Date().timeIntervalSince1970 + Double(rawResponse.expiresIn)
UserDefaults.standard.set(accessTimestamp, forKey: "RealDebrid.AccessTokenStamp")
// Set AppStorage variable
Task { @MainActor in
parentManager?.realDebridEnabled = true
}
}
public func fetchToken() async -> String? {
@ -186,10 +168,6 @@ public class RealDebrid: ObservableObject {
keychain.delete("RealDebrid.AccessToken")
}
Task { @MainActor in
parentManager?.realDebridEnabled = false
}
}
// Wrapper request function which matches the responses and returns data
@ -331,6 +309,14 @@ public class RealDebrid: ObservableObject {
}
}
// Deletes a torrent download from RD
public func deleteTorrent(debridID: String) async throws {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/torrents/delete/\(debridID)")!)
request.httpMethod = "DELETE"
try await performRequest(request: &request, requestName: #function)
}
// Downloads link from selectFiles for playback
public func unrestrictLink(debridDownloadLink: String) async throws -> String {
var request = URLRequest(url: URL(string: "\(baseApiUrl)/unrestrict/link")!)

View file

@ -7,7 +7,7 @@
import Foundation
public enum DefaultMagnetActionType: Int {
public enum DefaultMagnetActionType: Int, CaseIterable {
// Let the user choose
case none = 0
@ -18,7 +18,7 @@ public enum DefaultMagnetActionType: Int {
case shareMagnet = 2
}
public enum DefaultDebridActionType: Int {
public enum DefaultDebridActionType: Int, CaseIterable {
// Let the user choose
case none = 0

View file

@ -35,7 +35,7 @@ public struct SourceJson: Codable, Hashable {
}
public enum SourcePreferredParser: Int16, CaseIterable {
case none = 0
// case none = 0
case scraping = 1
case rss = 2
case siteApi = 3

View file

@ -8,24 +8,35 @@
import Foundation
import SwiftUI
@MainActor
public class DebridManager: ObservableObject {
// UI Variables
var toastModel: ToastViewModel?
@Published var showWebView: Bool = false
@Published var showLoadingProgress: Bool = false
// RealDebrid variables
// Service agnostic variables
@Published var currentDebridTask: Task<Void, Never>?
// RealDebrid auth variables
let realDebrid: RealDebrid = .init()
@AppStorage("RealDebrid.Enabled") var realDebridEnabled = false
@Published var realDebridHashes: [RealDebridIA] = []
@Published var realDebridEnabled: Bool = false {
didSet {
UserDefaults.standard.set(realDebridEnabled, forKey: "RealDebrid.Enabled")
}
}
@Published var realDebridAuthProcessing: Bool = false
@Published var realDebridAuthUrl: String = ""
// RealDebrid fetch variables
@Published var realDebridHashes: [RealDebridIA] = []
@Published var realDebridDownloadUrl: String = ""
@Published var selectedRealDebridItem: RealDebridIA?
@Published var selectedRealDebridFile: RealDebridIAFile?
init() {
realDebrid.parentManager = self
realDebridEnabled = UserDefaults.standard.bool(forKey: "RealDebrid.Enabled")
}
public func populateDebridHashes(_ searchResults: [SearchResult]) async {
@ -40,16 +51,12 @@ public class DebridManager: ObservableObject {
do {
let debridHashes = try await realDebrid.instantAvailability(magnetHashes: hashes)
Task { @MainActor in
realDebridHashes = debridHashes
}
realDebridHashes = debridHashes
} catch {
Task { @MainActor in
let error = error as NSError
let error = error as NSError
if error.code != -999 {
toastModel?.toastDescription = "RealDebrid hash error: \(error)"
}
if error.code != -999 {
toastModel?.updateToastDescription("RealDebrid hash error: \(error)")
}
print("RealDebrid hash error: \(error)")
@ -72,10 +79,9 @@ public class DebridManager: ObservableObject {
}
}
@MainActor
public func setSelectedRdResult(result: SearchResult) -> Bool {
guard let magnetHash = result.magnetHash else {
toastModel?.toastDescription = "Could not find the torrent magnet hash"
toastModel?.updateToastDescription("Could not find the torrent magnet hash")
return false
}
@ -83,40 +89,61 @@ public class DebridManager: ObservableObject {
selectedRealDebridItem = realDebridItem
return true
} else {
toastModel?.toastDescription = "Could not find the associated RealDebrid entry for magnet hash \(magnetHash)"
toastModel?.updateToastDescription("Could not find the associated RealDebrid entry for magnet hash \(magnetHash)")
return false
}
}
public func authenticateRd() async {
do {
let url = try await realDebrid.getVerificationInfo()
realDebridAuthProcessing = true
let verificationResponse = try await realDebrid.getVerificationInfo()
Task { @MainActor in
realDebridAuthUrl = url
showWebView.toggle()
}
realDebridAuthUrl = verificationResponse.directVerificationURL
showWebView.toggle()
try await realDebrid.getDeviceCredentials(deviceCode: verificationResponse.deviceCode)
realDebridEnabled = true
} catch {
Task { @MainActor in
toastModel?.toastDescription = "RealDebrid Authentication error: \(error)"
}
toastModel?.updateToastDescription("RealDebrid authentication error: \(error)")
realDebrid.authTask?.cancel()
print(error)
print("RealDebrid authentication error: \(error)")
}
}
public func logoutRd() async {
do {
try await realDebrid.deleteTokens()
realDebridEnabled = false
realDebridAuthProcessing = false
} catch {
toastModel?.updateToastDescription("RealDebrid logout error: \(error)")
print("RealDebrid logout error: \(error)")
}
}
public func fetchRdDownload(searchResult: SearchResult, iaFile: RealDebridIAFile? = nil) async {
defer {
currentDebridTask = nil
showLoadingProgress = false
}
showLoadingProgress = true
guard let magnetLink = searchResult.magnetLink else {
Task { @MainActor in
toastModel?.toastDescription = "Could not run your action because the magnet link is invalid."
}
print("RD error: Invalid magnet link")
toastModel?.updateToastDescription("Could not run your action because the magnet link is invalid.")
print("RealDebrid error: Invalid magnet link")
return
}
var realDebridId: String?
do {
let realDebridId = try await realDebrid.addMagnet(magnetLink: magnetLink)
realDebridId = try await realDebrid.addMagnet(magnetLink: magnetLink)
var fileIds: [Int] = []
@ -128,23 +155,34 @@ public class DebridManager: ObservableObject {
fileIds = iaBatchFromFile.files.map(\.id)
}
try await realDebrid.selectFiles(debridID: realDebridId, fileIds: fileIds)
if let realDebridId = realDebridId {
try await realDebrid.selectFiles(debridID: realDebridId, fileIds: fileIds)
let torrentLink = try await realDebrid.torrentInfo(debridID: realDebridId, selectedIndex: iaFile == nil ? 0 : iaFile?.batchFileIndex)
let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink)
let torrentLink = try await realDebrid.torrentInfo(debridID: realDebridId, selectedIndex: iaFile?.batchFileIndex ?? 0)
let downloadLink = try await realDebrid.unrestrictLink(debridDownloadLink: torrentLink)
let downloadUrlTask = Task { @MainActor in
realDebridDownloadUrl = downloadLink
} else {
toastModel?.updateToastDescription("Could not cache this torrent. Aborting.")
}
// Prevent a race condition when setting the published variable
await downloadUrlTask.value
} catch {
Task { @MainActor in
toastModel?.toastDescription = "RealDebrid download error: \(error)"
let error = error as NSError
switch error.code {
case -999:
toastModel?.updateToastDescription("Download cancelled", newToastType: .info)
default:
toastModel?.updateToastDescription("RealDebrid download error: \(error)")
}
print(error)
// Delete the torrent download if it exists
if let realDebridId = realDebridId {
try? await realDebrid.deleteTorrent(debridID: realDebridId)
}
showLoadingProgress = false
print("RealDebrid download error: \(error)")
}
}
}

View file

@ -25,6 +25,7 @@ class NavigationViewModel: ObservableObject {
case magnet
case batch
case activity
}
@Published var isEditingSearch: Bool = false
@ -34,7 +35,9 @@ class NavigationViewModel: ObservableObject {
@Published var currentChoiceSheet: ChoiceSheetType?
@Published var activityItems: [Any] = []
@Published var showActivityView: Bool = false
// Used to show the activity sheet in the share menu
@Published var showLocalActivitySheet = false
@Published var selectedTab: ViewTab = .search
@Published var showSearchProgress: Bool = false
@ -59,26 +62,26 @@ class NavigationViewModel: ObservableObject {
if let downloadUrl = URL(string: "outplayer://\(urlString)") {
UIApplication.shared.open(downloadUrl)
} else {
toastModel?.toastDescription = "Could not create an Outplayer URL"
toastModel?.updateToastDescription("Could not create an Outplayer URL")
}
case .vlc:
if let downloadUrl = URL(string: "vlc://\(urlString)") {
UIApplication.shared.open(downloadUrl)
} else {
toastModel?.toastDescription = "Could not create a VLC URL"
toastModel?.updateToastDescription("Could not create a VLC URL")
}
case .infuse:
if let downloadUrl = URL(string: "infuse://x-callback-url/play?url=\(urlString)") {
UIApplication.shared.open(downloadUrl)
} else {
toastModel?.toastDescription = "Could not create a Infuse URL"
toastModel?.updateToastDescription("Could not create a Infuse URL")
}
case .shareDownload:
if let downloadUrl = URL(string: urlString), currentChoiceSheet == nil {
activityItems = [downloadUrl]
showActivityView.toggle()
currentChoiceSheet = .activity
} else {
toastModel?.toastDescription = "Could not create object for sharing"
toastModel?.updateToastDescription("Could not create object for sharing")
}
}
}
@ -100,16 +103,16 @@ class NavigationViewModel: ObservableObject {
if let url = URL(string: "https://webtor.io/#/show?magnet=\(magnetLink)") {
UIApplication.shared.open(url)
} else {
toastModel?.toastDescription = "Could not create a WebTor URL"
toastModel?.updateToastDescription("Could not create a WebTor URL")
}
case .shareMagnet:
if let magnetUrl = URL(string: magnetLink),
currentChoiceSheet == nil
{
activityItems = [magnetUrl]
showActivityView.toggle()
currentChoiceSheet = .activity
} else {
toastModel?.toastDescription = "Could not create object for sharing"
toastModel?.updateToastDescription("Could not create object for sharing")
}
}
}

View file

@ -36,12 +36,13 @@ class ScrapingViewModel: ObservableObject {
@Published var currentSourceName: String?
@MainActor
func updateSearchResults(newResults: [SearchResult]) {
searchResults = newResults
}
public func scanSources(sources: [Source]) async {
if sources.isEmpty {
Task { @MainActor in
toastModel?.toastType = .info
toastModel?.toastDescription = "There are no sources to search!"
}
await toastModel?.updateToastDescription("There are no sources to search!", newToastType: .info)
print("There are no sources to search!")
return
@ -51,10 +52,12 @@ class ScrapingViewModel: ObservableObject {
for source in sources {
if source.enabled {
currentSourceName = source.name
Task { @MainActor in
currentSourceName = source.name
}
guard let baseUrl = source.baseUrl else {
toastModel?.toastDescription = "The base URL could not be found for source \(source.name)"
await toastModel?.updateToastDescription("The base URL could not be found for source \(source.name)")
print("The base URL could not be found for source \(source.name)")
continue
@ -64,7 +67,7 @@ class ScrapingViewModel: ObservableObject {
let preferredParser = SourcePreferredParser(rawValue: source.preferredParser) ?? .none
guard let encodedQuery = searchText.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
toastModel?.toastDescription = "Could not process search query, invalid characters present."
await toastModel?.updateToastDescription("Could not process search query, invalid characters present.")
print("Could not process search query, invalid characters present")
continue
@ -110,7 +113,7 @@ class ScrapingViewModel: ObservableObject {
if let data = data,
let rss = String(data: data, encoding: .utf8)
{
let sourceResults = scrapeRss(source: source, rss: rss)
let sourceResults = await scrapeRss(source: source, rss: rss)
tempResults += sourceResults
}
}
@ -154,7 +157,7 @@ class ScrapingViewModel: ObservableObject {
)
if let data = data {
let sourceResults = scrapeJson(source: source, jsonData: data)
let sourceResults = await scrapeJson(source: source, jsonData: data)
tempResults += sourceResults
}
}
@ -169,7 +172,7 @@ class ScrapingViewModel: ObservableObject {
return
}
searchResults = tempResults
await updateSearchResults(newResults: tempResults)
}
// Checks the base URL for any website data then iterates through the fallback URLs
@ -232,7 +235,7 @@ class ScrapingViewModel: ObservableObject {
public func fetchApiCredential(urlString: String, credential: SourceApiCredential) async -> String? {
guard let url = URL(string: urlString) else {
Task { @MainActor in
toastModel?.toastDescription = "This token URL is invalid."
toastModel?.updateToastDescription("This token URL is invalid.")
}
print("Token url \(urlString) is invalid!")
@ -259,17 +262,15 @@ class ScrapingViewModel: ObservableObject {
} catch {
let error = error as NSError
Task { @MainActor in
switch error.code {
case -999:
toastModel?.toastType = .info
toastModel?.toastDescription = "Search cancelled"
case -1001:
toastModel?.toastDescription = "Credentials request timed out"
default:
toastModel?.toastDescription = "Error in fetching an API credential \(error)"
}
switch error.code {
case -999:
await toastModel?.updateToastDescription("Search cancelled", newToastType: .info)
case -1001:
await toastModel?.updateToastDescription("Credentials request timed out")
default:
await toastModel?.updateToastDescription("Error in fetching an API credential \(error)")
}
print("Error in fetching an API credential \(error)")
return nil
@ -279,9 +280,7 @@ class ScrapingViewModel: ObservableObject {
// Fetches the data for a URL
public func fetchWebsiteData(urlString: String) async -> Data? {
guard let url = URL(string: urlString) else {
Task { @MainActor in
toastModel?.toastDescription = "Source doesn't contain a valid URL, contact the source dev!"
}
await toastModel?.updateToastDescription("Source doesn't contain a valid URL, contact the source dev!")
print("Source doesn't contain a valid URL, contact the source dev!")
@ -296,24 +295,22 @@ class ScrapingViewModel: ObservableObject {
} catch {
let error = error as NSError
Task { @MainActor in
switch error.code {
case -999:
toastModel?.toastType = .info
toastModel?.toastDescription = "Search cancelled"
case -1001:
toastModel?.toastDescription = "Data request timed out. Trying fallback URLs if present."
default:
toastModel?.toastDescription = "Error in fetching website data \(error)"
}
switch error.code {
case -999:
await toastModel?.updateToastDescription("Search cancelled", newToastType: .info)
case -1001:
await toastModel?.updateToastDescription("Data request timed out. Trying fallback URLs if present.")
default:
await toastModel?.updateToastDescription("Error in fetching website data \(error)")
}
print("Error in fetching data \(error)")
return nil
}
}
public func scrapeJson(source: Source, jsonData: Data) -> [SearchResult] {
public func scrapeJson(source: Source, jsonData: Data) async -> [SearchResult] {
var tempResults: [SearchResult] = []
guard let jsonParser = source.jsonParser else {
@ -332,9 +329,7 @@ class ScrapingViewModel: ObservableObject {
}
} catch {
if let api = source.api {
Task { @MainActor in
cleanApiCreds(api: api)
}
await cleanApiCreds(api: api)
print("JSON parsing error, couldn't fetch results: \(error)")
}
@ -342,9 +337,7 @@ class ScrapingViewModel: ObservableObject {
// If there are no results and the client secret isn't dynamic, just clear out the token
if let api = source.api, jsonResults.isEmpty {
Task { @MainActor in
cleanApiCreds(api: api)
}
await cleanApiCreds(api: api)
print("JSON results were empty!")
}
@ -470,7 +463,7 @@ class ScrapingViewModel: ObservableObject {
}
// RSS feed scraper
public func scrapeRss(source: Source, rss: String) -> [SearchResult] {
public func scrapeRss(source: Source, rss: String) async -> [SearchResult] {
var tempResults: [SearchResult] = []
guard let rssParser = source.rssParser else {
@ -483,9 +476,7 @@ class ScrapingViewModel: ObservableObject {
let document = try SwiftSoup.parse(rss, "", Parser.xmlParser())
items = try document.getElementsByTag("item")
} catch {
Task { @MainActor in
toastModel?.toastDescription = "RSS scraping error, couldn't fetch items: \(error)"
}
await toastModel?.updateToastDescription("RSS scraping error, couldn't fetch items: \(error)")
print("RSS scraping error, couldn't fetch items: \(error)")
return tempResults
@ -640,9 +631,7 @@ class ScrapingViewModel: ObservableObject {
let document = try SwiftSoup.parse(html)
rows = try document.select(htmlParser.rows)
} catch {
Task { @MainActor in
toastModel?.toastDescription = "Scraping error, couldn't fetch rows: \(error)"
}
await toastModel?.updateToastDescription("Scraping error, couldn't fetch rows: \(error)")
print("Scraping error, couldn't fetch rows: \(error)")
return tempResults
@ -775,9 +764,7 @@ class ScrapingViewModel: ObservableObject {
tempResults.append(result)
}
} catch {
Task { @MainActor in
toastModel?.toastDescription = "Scraping error: \(error)"
}
await toastModel?.updateToastDescription("Scraping error: \(error)")
print("Scraping error: \(error)")
continue
@ -880,8 +867,7 @@ class ScrapingViewModel: ObservableObject {
return magnetLinkArray.joined()
}
@MainActor
func cleanApiCreds(api: SourceApi) {
func cleanApiCreds(api: SourceApi) async {
let backgroundContext = PersistenceController.shared.backgroundContext
let hasCredentials = api.clientId != nil || api.clientSecret != nil
@ -926,7 +912,7 @@ class ScrapingViewModel: ObservableObject {
}
}
toastModel?.toastDescription = responseArray.joined()
await toastModel?.updateToastDescription(responseArray.joined())
PersistenceController.shared.save(backgroundContext)
}

View file

@ -92,16 +92,14 @@ public class SourceManager: ObservableObject {
}
}
public func installSource(sourceJson: SourceJson, doUpsert: Bool = false) {
public func installSource(sourceJson: SourceJson, doUpsert: Bool = false) async {
let backgroundContext = PersistenceController.shared.backgroundContext
// If there's no base URL and it isn't dynamic, return before any transactions occur
let dynamicBaseUrl = sourceJson.dynamicBaseUrl ?? false
if !dynamicBaseUrl, sourceJson.baseUrl == nil {
Task { @MainActor in
toastModel?.toastDescription = "Not adding this source because base URL parameters are malformed. Please contact the source dev."
}
await toastModel?.updateToastDescription("Not adding this source because base URL parameters are malformed. Please contact the source dev.")
print("Not adding this source because base URL parameters are malformed")
return
@ -116,9 +114,7 @@ public class SourceManager: ObservableObject {
if doUpsert {
PersistenceController.shared.delete(existingSource, context: backgroundContext)
} else {
Task { @MainActor in
toastModel?.toastDescription = "Could not install source with name \(sourceJson.name) because it is already installed."
}
await toastModel?.updateToastDescription("Could not install source with name \(sourceJson.name) because it is already installed.")
return
}
}
@ -166,9 +162,7 @@ public class SourceManager: ObservableObject {
do {
try backgroundContext.save()
} catch {
Task { @MainActor in
toastModel?.toastDescription = error.localizedDescription
}
await toastModel?.updateToastDescription(error.localizedDescription)
}
}

View file

@ -35,6 +35,14 @@ class ToastViewModel: ObservableObject {
@Published var showToast: Bool = false
public func updateToastDescription(_ description: String, newToastType: ToastType? = nil) {
if let newToastType = newToastType {
toastType = newToastType
}
toastDescription = description
}
// Default the toast type to error since the majority of toasts are errors
@Published var toastType: ToastType = .error
}

View file

@ -22,7 +22,7 @@ struct BatchChoiceView: View {
debridManager.selectedRealDebridFile = file
if let searchResult = scrapingModel.selectedSearchResult {
Task {
debridManager.currentDebridTask = Task {
await debridManager.fetchRdDownload(searchResult: searchResult, iaFile: file)
if !debridManager.realDebridDownloadUrl.isEmpty {

View file

@ -0,0 +1,44 @@
//
// IndeterminateProgressView.swift
// Ferrite
//
// Created by Brian Dashore on 8/26/22.
//
// Inspired by https://daringsnowball.net/articles/indeterminate-linear-progress-view/
//
import SwiftUI
struct IndeterminateProgressView: View {
@State private var offset: CGFloat = 0
var body: some View {
GeometryReader { reader in
Rectangle()
.foregroundColor(.gray.opacity(0.15))
.overlay(
Rectangle()
.foregroundColor(Color.accentColor)
.frame(width: reader.size.width * 0.26, height: 6)
.clipShape(Capsule())
.offset(x: -reader.size.width * 0.6, y: 0)
.offset(x: reader.size.width * 1.2 * self.offset, y: 0)
.animation(.default.repeatForever().speed(0.5), value: self.offset)
.onAppear{
withAnimation {
self.offset = 1
}
}
)
.clipShape(Capsule())
}
.frame(height: 4, alignment: .center)
}
}
struct IndeterminateProgressView_Previews: PreviewProvider {
static var previews: some View {
IndeterminateProgressView()
}
}

View file

@ -85,18 +85,17 @@ struct ContentView: View {
.environmentObject(debridManager)
.environmentObject(scrapingModel)
.environmentObject(navModel)
case .activity:
if #available(iOS 16, *) {
AppActivityView(activityItems: navModel.activityItems)
.presentationDetents([.medium, .large])
} else {
AppActivityView(activityItems: navModel.activityItems)
}
}
}
.dynamicAccentColor(.primary)
}
.sheet(isPresented: $navModel.showActivityView) {
if #available(iOS 16, *) {
AppActivityView(activityItems: navModel.activityItems)
.presentationDetents([.medium])
} else {
AppActivityView(activityItems: navModel.activityItems)
}
}
.navigationTitle("Search")
.navigationSearchBar {
SearchBar("Search",

View file

@ -17,10 +17,8 @@ struct MagnetChoiceView: View {
@AppStorage("RealDebrid.Enabled") var realDebridEnabled = false
@State private var showActivityView = false
@State private var showLinkCopyAlert = false
@State private var showMagnetCopyAlert = false
@State private var activityItems: [Any] = []
var body: some View {
NavView {
@ -53,8 +51,8 @@ struct MagnetChoiceView: View {
ListRowButtonView("Share download URL", systemImage: "square.and.arrow.up.fill") {
if let url = URL(string: debridManager.realDebridDownloadUrl) {
activityItems = [url]
navModel.showActivityView.toggle()
navModel.activityItems = [url]
navModel.showLocalActivitySheet.toggle()
}
}
}
@ -78,8 +76,8 @@ struct MagnetChoiceView: View {
let magnetLink = result.magnetLink,
let url = URL(string: magnetLink)
{
activityItems = [url]
navModel.showActivityView.toggle()
navModel.activityItems = [url]
navModel.showLocalActivitySheet.toggle()
}
}
@ -90,12 +88,12 @@ struct MagnetChoiceView: View {
}
}
}
.sheet(isPresented: $navModel.showActivityView) {
.sheet(isPresented: $navModel.showLocalActivitySheet) {
if #available(iOS 16, *) {
AppActivityView(activityItems: activityItems)
.presentationDetents([.medium])
AppActivityView(activityItems: navModel.activityItems)
.presentationDetents([.medium, .large])
} else {
AppActivityView(activityItems: activityItems)
AppActivityView(activityItems: navModel.activityItems)
}
}
.navigationTitle("Link actions")

View file

@ -11,6 +11,7 @@ import SwiftUIX
struct MainView: View {
@EnvironmentObject var navModel: NavigationViewModel
@EnvironmentObject var toastModel: ToastViewModel
@EnvironmentObject var debridManager: DebridManager
var body: some View {
TabView(selection: $navModel.selectedTab) {
@ -52,11 +53,34 @@ struct MainView: View {
.cornerRadius(10)
}
if debridManager.showLoadingProgress {
VStack {
Text("Loading content")
HStack {
IndeterminateProgressView()
Button("Cancel") {
debridManager.currentDebridTask?.cancel()
debridManager.currentDebridTask = nil
debridManager.showLoadingProgress = false
}
}
}
.padding(12)
.font(.caption)
.background {
VisualEffectBlurView(blurStyle: .systemThinMaterial)
}
.cornerRadius(10)
.frame(width: 200)
}
Rectangle()
.foregroundColor(.clear)
.frame(height: 60)
}
.animation(.easeInOut(duration: 0.3), value: toastModel.showToast)
.animation(.easeInOut(duration: 0.3), value: toastModel.showToast || debridManager.showLoadingProgress)
}
}
}

View file

@ -20,23 +20,25 @@ struct SearchResultsView: View {
if result.source == scrapingModel.filteredSource?.name || scrapingModel.filteredSource == nil {
VStack(alignment: .leading) {
Button {
scrapingModel.selectedSearchResult = result
if debridManager.currentDebridTask == nil {
scrapingModel.selectedSearchResult = result
switch debridManager.matchSearchResult(result: result) {
case .full:
Task {
await debridManager.fetchRdDownload(searchResult: result)
switch debridManager.matchSearchResult(result: result) {
case .full:
debridManager.currentDebridTask = Task {
await debridManager.fetchRdDownload(searchResult: result)
if !debridManager.realDebridDownloadUrl.isEmpty {
navModel.runDebridAction(action: nil, urlString: debridManager.realDebridDownloadUrl)
if !debridManager.realDebridDownloadUrl.isEmpty {
navModel.runDebridAction(action: nil, urlString: debridManager.realDebridDownloadUrl)
}
}
case .partial:
if debridManager.setSelectedRdResult(result: result) {
navModel.currentChoiceSheet = .batch
}
case .none:
navModel.runMagnetAction(action: nil, searchResult: result)
}
case .partial:
if debridManager.setSelectedRdResult(result: result) {
navModel.currentChoiceSheet = .batch
}
case .none:
navModel.runMagnetAction(action: nil, searchResult: result)
}
} label: {
Text(result.title ?? "No title")

View file

@ -13,12 +13,9 @@ struct SettingsView: View {
let backgroundContext = PersistenceController.shared.backgroundContext
@AppStorage("RealDebrid.Enabled") var realDebridEnabled = false
@AppStorage("Actions.DefaultDebrid") var defaultDebridAction: DefaultDebridActionType = .none
@AppStorage("Actions.DefaultMagnet") var defaultMagnetAction: DefaultMagnetActionType = .none
@State private var isProcessing = false
var body: some View {
NavView {
Form {
@ -28,16 +25,15 @@ struct SettingsView: View {
Spacer()
Button {
Task {
if realDebridEnabled {
try? await debridManager.realDebrid.deleteTokens()
} else if !isProcessing {
if debridManager.realDebridEnabled {
await debridManager.logoutRd()
} else if !debridManager.realDebridAuthProcessing {
await debridManager.authenticateRd()
isProcessing = true
}
}
} label: {
Text(realDebridEnabled ? "Logout" : (isProcessing ? "Processing" : "Login"))
.foregroundColor(realDebridEnabled ? .red : .blue)
Text(debridManager.realDebridEnabled ? "Logout" : (debridManager.realDebridAuthProcessing ? "Processing" : "Login"))
.foregroundColor(debridManager.realDebridEnabled ? .red : .blue)
}
}
}
@ -47,7 +43,7 @@ struct SettingsView: View {
}
Section(header: "Default actions") {
if realDebridEnabled {
if debridManager.realDebridEnabled {
NavigationLink(
destination: DebridActionPickerView(),
label: {

View file

@ -12,20 +12,37 @@ struct MagnetActionPickerView: View {
var body: some View {
List {
Picker(selection: $defaultMagnetAction, label: EmptyView()) {
Text("Let me choose")
.tag(DefaultMagnetActionType.none)
Text("Open in Webtor")
.tag(DefaultMagnetActionType.webtor)
Text("Share magnet link")
.tag(DefaultMagnetActionType.shareMagnet)
ForEach(DefaultMagnetActionType.allCases, id: \.self) { action in
Button {
defaultMagnetAction = action
} label: {
HStack {
Text(fetchPickerChoiceName(choice: action))
Spacer()
if action == defaultMagnetAction {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
}
.dynamicAccentColor(.primary)
}
}
.pickerStyle(.inline)
.listStyle(.insetGrouped)
.navigationTitle("Default magnet action")
.navigationBarTitleDisplayMode(.inline)
}
func fetchPickerChoiceName(choice: DefaultMagnetActionType) -> String {
switch choice {
case .none:
return "Let me choose"
case .webtor:
return "Open in Webtor"
case .shareMagnet:
return "Share magnet link"
}
}
}
struct DebridActionPickerView: View {
@ -33,22 +50,39 @@ struct DebridActionPickerView: View {
var body: some View {
List {
Picker(selection: $defaultDebridAction, label: EmptyView()) {
Text("Let me choose")
.tag(DefaultDebridActionType.none)
Text("Open in Outplayer")
.tag(DefaultDebridActionType.outplayer)
Text("Open in VLC")
.tag(DefaultDebridActionType.vlc)
Text("Open in Infuse")
.tag(DefaultDebridActionType.infuse)
Text("Share download link")
.tag(DefaultDebridActionType.shareDownload)
ForEach(DefaultDebridActionType.allCases, id: \.self) { action in
Button {
defaultDebridAction = action
} label: {
HStack {
Text(fetchPickerChoiceName(choice: action))
Spacer()
if action == defaultDebridAction {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
}
.dynamicAccentColor(.primary)
}
}
.pickerStyle(.inline)
.listStyle(.insetGrouped)
.navigationTitle("Default debrid action")
.navigationBarTitleDisplayMode(.inline)
}
func fetchPickerChoiceName(choice: DefaultDebridActionType) -> String {
switch choice {
case .none:
return "Let me choose"
case .outplayer:
return "Open in Outplayer"
case .vlc:
return "Open in VLC"
case .infuse:
return "Open in Infuse"
case .shareDownload:
return "Share download link"
}
}
}

View file

@ -51,6 +51,7 @@ struct SettingsSourceListView: View {
}
}
}
.listStyle(.insetGrouped)
.sheet(isPresented: $presentSourceSheet) {
if #available(iOS 16, *) {
SourceListEditorView()

View file

@ -28,7 +28,9 @@ struct SourceCatalogButtonView: View {
Spacer()
Button("Install") {
sourceManager.installSource(sourceJson: availableSource)
Task {
await sourceManager.installSource(sourceJson: availableSource)
}
}
}
}

View file

@ -144,31 +144,53 @@ struct SourceSettingsApiView: View {
struct SourceSettingsMethodView: View {
@ObservedObject var selectedSource: Source
@State private var selectedTempParser: SourcePreferredParser = .none
var body: some View {
Picker("Fetch method", selection: $selectedTempParser) {
if selectedSource.jsonParser != nil {
Text("Website API")
.tag(SourcePreferredParser.siteApi)
Section(header: Text("Fetch method")) {
if selectedSource.api != nil, selectedSource.jsonParser != nil {
Button {
selectedSource.preferredParser = SourcePreferredParser.siteApi.rawValue
} label: {
HStack {
Text("Website API")
Spacer()
if SourcePreferredParser.siteApi.rawValue == selectedSource.preferredParser {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
}
}
if selectedSource.rssParser != nil {
Text("RSS")
.tag(SourcePreferredParser.rss)
Button {
selectedSource.preferredParser = SourcePreferredParser.rss.rawValue
} label: {
HStack {
Text("RSS")
Spacer()
if SourcePreferredParser.rss.rawValue == selectedSource.preferredParser {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
}
}
if selectedSource.htmlParser != nil {
Text("Web scraping")
.tag(SourcePreferredParser.scraping)
Button {
selectedSource.preferredParser = SourcePreferredParser.scraping.rawValue
} label: {
HStack {
Text("Web scraping")
Spacer()
if SourcePreferredParser.scraping.rawValue == selectedSource.preferredParser {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
}
}
}
.pickerStyle(.inline)
.onAppear {
selectedTempParser = SourcePreferredParser(rawValue: selectedSource.preferredParser) ?? .none
}
.onChange(of: selectedTempParser) { _ in
selectedSource.preferredParser = selectedTempParser.rawValue
}
.dynamicAccentColor(.primary)
}
}

View file

@ -28,7 +28,9 @@ struct SourceUpdateButtonView: View {
Spacer()
Button("Update") {
sourceManager.installSource(sourceJson: updatedSource, doUpsert: true)
Task {
await sourceManager.installSource(sourceJson: updatedSource, doUpsert: true)
}
}
}
}