From 51fe07e463b1bd66ced8f6f101c0abb2a937f900 Mon Sep 17 00:00:00 2001 From: realdoomsboygaming <105606471+realdoomsboygaming@users.noreply.github.com> Date: Mon, 9 Jun 2025 23:34:39 -0700 Subject: [PATCH 01/52] Many Fix (#165) * FIx MP4 Thumbnails * Fix Play/Pause State Management for MP4 downlaods * Yes * Fix All The Things --- .../Utils/DownloadUtils/DownloadManager.swift | 2 +- .../Downloads/JSController+MP4Download.swift | 265 +++++------------- .../Downloads/JSController-Downloads.swift | 138 +++++++-- .../JSController-StreamTypeDownload.swift | 3 +- .../CustomPlayer/CustomPlayer.swift | 257 +++++++++++------ Sora/Views/DownloadView.swift | 77 +++-- 6 files changed, 390 insertions(+), 352 deletions(-) diff --git a/Sora/Utils/DownloadUtils/DownloadManager.swift b/Sora/Utils/DownloadUtils/DownloadManager.swift index 9c3d90d..454f6a5 100644 --- a/Sora/Utils/DownloadUtils/DownloadManager.swift +++ b/Sora/Utils/DownloadUtils/DownloadManager.swift @@ -54,7 +54,7 @@ class DownloadManager: NSObject, ObservableObject { options: .skipsHiddenFiles ) - if let localURL = contents.first(where: { $0.pathExtension == "movpkg" }) { + if let localURL = contents.first(where: { ["movpkg", "mp4"].contains($0.pathExtension.lowercased()) }) { localPlaybackURL = localURL } } catch { diff --git a/Sora/Utils/JSLoader/Downloads/JSController+MP4Download.swift b/Sora/Utils/JSLoader/Downloads/JSController+MP4Download.swift index 4f8090c..27e12e6 100644 --- a/Sora/Utils/JSLoader/Downloads/JSController+MP4Download.swift +++ b/Sora/Utils/JSLoader/Downloads/JSController+MP4Download.swift @@ -7,11 +7,12 @@ import Foundation import SwiftUI +import AVFoundation -// Extension for handling MP4 direct video downloads +// Extension for handling MP4 direct video downloads using AVAssetDownloadTask extension JSController { - /// Initiates a download for a given MP4 URL + /// Initiates a download for a given MP4 URL using the existing AVAssetDownloadURLSession /// - Parameters: /// - url: The MP4 URL to download /// - headers: HTTP headers to use for the request @@ -26,24 +27,21 @@ extension JSController { func downloadMP4(url: URL, headers: [String: String], title: String? = nil, imageURL: URL? = nil, isEpisode: Bool = false, showTitle: String? = nil, season: Int? = nil, episode: Int? = nil, - subtitleURL: URL? = nil, + subtitleURL: URL? = nil, showPosterURL: URL? = nil, completionHandler: ((Bool, String) -> Void)? = nil) { - print("---- MP4 DOWNLOAD PROCESS STARTED ----") - print("MP4 URL: \(url.absoluteString)") - print("Headers: \(headers)") - print("Title: \(title ?? "None")") - print("Is Episode: \(isEpisode), Show: \(showTitle ?? "None"), Season: \(season?.description ?? "None"), Episode: \(episode?.description ?? "None")") - if let subtitle = subtitleURL { - print("Subtitle URL: \(subtitle.absoluteString)") - } - // Validate URL guard url.scheme == "http" || url.scheme == "https" else { completionHandler?(false, "Invalid URL scheme") return } + // Ensure download session is available + guard let downloadSession = downloadURLSession else { + completionHandler?(false, "Download session not available") + return + } + // Create metadata for the download var metadata: AssetMetadata? = nil if let title = title { @@ -53,7 +51,7 @@ extension JSController { showTitle: showTitle, season: season, episode: episode, - showPosterURL: imageURL + showPosterURL: showPosterURL ?? imageURL ) } @@ -63,233 +61,98 @@ extension JSController { // Generate a unique download ID let downloadID = UUID() - // Get access to the download directory - guard let downloadDirectory = getPersistentDownloadDirectory() else { - print("MP4 Download: Failed to get download directory") - completionHandler?(false, "Failed to create download directory") + // Create AVURLAsset with headers passed through AVURLAssetHTTPHeaderFieldsKey + let asset = AVURLAsset(url: url, options: [ + "AVURLAssetHTTPHeaderFieldsKey": headers + ]) + + // Create AVAssetDownloadTask using existing session + guard let downloadTask = downloadSession.makeAssetDownloadTask( + asset: asset, + assetTitle: title ?? url.lastPathComponent, + assetArtworkData: nil, + options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 2_000_000] + ) else { + completionHandler?(false, "Failed to create download task") return } - // Generate a safe filename for the MP4 file - let sanitizedTitle = title?.replacingOccurrences(of: "[^A-Za-z0-9 ._-]", with: "", options: .regularExpression) ?? "download" - let filename = "\(sanitizedTitle)_\(downloadID.uuidString.prefix(8)).mp4" - let destinationURL = downloadDirectory.appendingPathComponent(filename) - // Create an active download object let activeDownload = JSActiveDownload( id: downloadID, originalURL: url, - task: nil, + progress: 0.0, + task: downloadTask, + urlSessionTask: nil, queueStatus: .downloading, type: downloadType, metadata: metadata, title: title, imageURL: imageURL, subtitleURL: subtitleURL, - headers: headers + asset: asset, + headers: headers, + module: nil ) - // Add to active downloads + // Add to active downloads and tracking activeDownloads.append(activeDownload) + activeDownloadMap[downloadTask] = downloadID - // Create request with headers - var request = URLRequest(url: url) - request.timeoutInterval = 30.0 - for (key, value) in headers { - request.addValue(value, forHTTPHeaderField: key) - } + // Set up progress observation for MP4 downloads + setupMP4ProgressObservation(for: downloadTask, downloadID: downloadID) - let sessionConfig = URLSessionConfiguration.default - sessionConfig.timeoutIntervalForRequest = 60.0 - sessionConfig.timeoutIntervalForResource = 1800.0 - sessionConfig.httpMaximumConnectionsPerHost = 1 - sessionConfig.allowsCellularAccess = true - - // Create custom session with delegate (self is JSController, which is persistent) - let customSession = URLSession(configuration: sessionConfig, delegate: self, delegateQueue: nil) - - // Create the download task - let downloadTask = customSession.downloadTask(with: request) { [weak self] (tempURL, response, error) in - guard let self = self else { return } - - DispatchQueue.main.async { - defer { - // Clean up resources - self.cleanupDownloadResources(for: downloadID) - } - - // Handle error cases - just remove from active downloads - if let error = error { - print("MP4 Download Error: \(error.localizedDescription)") - self.removeActiveDownload(downloadID: downloadID) - completionHandler?(false, "Download failed: \(error.localizedDescription)") - return - } - - // Validate response - guard let httpResponse = response as? HTTPURLResponse else { - print("MP4 Download: Invalid response") - self.removeActiveDownload(downloadID: downloadID) - completionHandler?(false, "Invalid server response") - return - } - - guard (200...299).contains(httpResponse.statusCode) else { - print("MP4 Download HTTP Error: \(httpResponse.statusCode)") - self.removeActiveDownload(downloadID: downloadID) - completionHandler?(false, "Server error: \(httpResponse.statusCode)") - return - } - - guard let tempURL = tempURL else { - print("MP4 Download: No temporary file URL") - self.removeActiveDownload(downloadID: downloadID) - completionHandler?(false, "Download data not available") - return - } - - // Move file to final destination - do { - if FileManager.default.fileExists(atPath: destinationURL.path) { - try FileManager.default.removeItem(at: destinationURL) - } - - try FileManager.default.moveItem(at: tempURL, to: destinationURL) - print("MP4 Download: Successfully saved to \(destinationURL.path)") - - // Verify file size - let fileSize = try FileManager.default.attributesOfItem(atPath: destinationURL.path)[.size] as? Int64 ?? 0 - guard fileSize > 0 else { - throw NSError(domain: "DownloadError", code: -1, userInfo: [NSLocalizedDescriptionKey: "Downloaded file is empty"]) - } - - // Create downloaded asset - let downloadedAsset = DownloadedAsset( - name: title ?? url.lastPathComponent, - downloadDate: Date(), - originalURL: url, - localURL: destinationURL, - type: downloadType, - metadata: metadata, - subtitleURL: subtitleURL - ) - - // Save asset - self.savedAssets.append(downloadedAsset) - self.saveAssets() - - // Update progress to complete and remove after delay - self.updateDownloadProgress(downloadID: downloadID, progress: 1.0) - - // Download subtitle if provided - if let subtitleURL = subtitleURL { - self.downloadSubtitle(subtitleURL: subtitleURL, assetID: downloadedAsset.id.uuidString) - } - - // Notify completion - NotificationCenter.default.post(name: NSNotification.Name("downloadCompleted"), object: downloadedAsset) - completionHandler?(true, "Download completed successfully") - - // Remove from active downloads after success - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - self.removeActiveDownload(downloadID: downloadID) - } - - } catch { - print("MP4 Download Error saving file: \(error.localizedDescription)") - self.removeActiveDownload(downloadID: downloadID) - completionHandler?(false, "Error saving download: \(error.localizedDescription)") - } - } - } - - // Set up progress observation - setupProgressObservation(for: downloadTask, downloadID: downloadID) - - // Store session reference - storeSessionReference(session: customSession, for: downloadID) - - // Start download + // Start the download downloadTask.resume() - print("MP4 Download: Task started for \(filename)") + + // Post notification for UI updates using NotificationCenter directly since postDownloadNotification is private + DispatchQueue.main.async { + NotificationCenter.default.post(name: NSNotification.Name("downloadStatusChanged"), object: nil) + } // Initial success callback completionHandler?(true, "Download started") } - // MARK: - Helper Methods + // MARK: - MP4 Progress Observation - private func removeActiveDownload(downloadID: UUID) { - activeDownloads.removeAll { $0.id == downloadID } - } - - private func updateDownloadProgress(downloadID: UUID, progress: Double) { - guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else { return } - activeDownloads[index].progress = progress - } - - private func setupProgressObservation(for task: URLSessionDownloadTask, downloadID: UUID) { - let observation = task.progress.observe(\.fractionCompleted) { [weak self] progress, _ in + /// Sets up progress observation for MP4 downloads using AVAssetDownloadTask + /// Since AVAssetDownloadTask doesn't provide progress for single MP4 files through delegate methods, + /// we observe the task's progress property directly + private func setupMP4ProgressObservation(for task: AVAssetDownloadTask, downloadID: UUID) { + let observation = task.progress.observe(\.fractionCompleted, options: [.new]) { [weak self] progress, _ in DispatchQueue.main.async { guard let self = self else { return } - self.updateDownloadProgress(downloadID: downloadID, progress: progress.fractionCompleted) + + // Update download progress using existing infrastructure + self.updateMP4DownloadProgress(task: task, progress: progress.fractionCompleted) + + // Post notification for UI updates NotificationCenter.default.post(name: NSNotification.Name("downloadProgressChanged"), object: nil) } } + // Store observation for cleanup using existing property from main JSController class if mp4ProgressObservations == nil { mp4ProgressObservations = [:] } mp4ProgressObservations?[downloadID] = observation } - private func storeSessionReference(session: URLSession, for downloadID: UUID) { - if mp4CustomSessions == nil { - mp4CustomSessions = [:] - } - mp4CustomSessions?[downloadID] = session - } - - private func cleanupDownloadResources(for downloadID: UUID) { - mp4ProgressObservations?[downloadID] = nil - mp4CustomSessions?[downloadID] = nil - } -} - -// MARK: - URLSessionDelegate -extension JSController: URLSessionDelegate { - func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge, completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) { - - guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust else { - completionHandler(.performDefaultHandling, nil) + /// Updates download progress for a specific MP4 task (avoiding name collision with existing method) + private func updateMP4DownloadProgress(task: AVAssetDownloadTask, progress: Double) { + guard let downloadID = activeDownloadMap[task], + let downloadIndex = activeDownloads.firstIndex(where: { $0.id == downloadID }) else { return } - let host = challenge.protectionSpace.host - print("MP4 Download: Handling server trust challenge for host: \(host)") - - // Define trusted hosts for MP4 downloads - let trustedHosts = [ - "streamtales.cc", - "frembed.xyz", - "vidclouds.cc" - ] - - let isTrustedHost = trustedHosts.contains { host.contains($0) } - let isCustomSession = mp4CustomSessions?.values.contains(session) == true - - if isTrustedHost || isCustomSession { - guard let serverTrust = challenge.protectionSpace.serverTrust else { - completionHandler(.performDefaultHandling, nil) - return - } - - print("MP4 Download: Accepting certificate for \(host)") - let credential = URLCredential(trust: serverTrust) - completionHandler(.useCredential, credential) - } else { - print("MP4 Download: Using default handling for \(host)") - completionHandler(.performDefaultHandling, nil) - } + // Update progress using existing mechanism + activeDownloads[downloadIndex].progress = progress + } + + /// Cleans up MP4 progress observation for a specific download + func cleanupMP4ProgressObservation(for downloadID: UUID) { + mp4ProgressObservations?[downloadID]?.invalidate() + mp4ProgressObservations?[downloadID] = nil } } diff --git a/Sora/Utils/JSLoader/Downloads/JSController-Downloads.swift b/Sora/Utils/JSLoader/Downloads/JSController-Downloads.swift index a6e9f6b..be358fe 100644 --- a/Sora/Utils/JSLoader/Downloads/JSController-Downloads.swift +++ b/Sora/Utils/JSLoader/Downloads/JSController-Downloads.swift @@ -162,6 +162,7 @@ extension JSController { originalURL: url, progress: 0, task: nil, // Task will be created when the download starts + urlSessionTask: nil, queueStatus: .queued, type: downloadType, metadata: assetMetadata, @@ -299,6 +300,7 @@ extension JSController { originalURL: queuedDownload.originalURL, progress: 0, task: task, + urlSessionTask: nil, queueStatus: .downloading, type: queuedDownload.type, metadata: queuedDownload.metadata, @@ -364,6 +366,11 @@ extension JSController { private func cleanupDownloadTask(_ task: URLSessionTask) { guard let downloadID = activeDownloadMap[task] else { return } + // Clean up MP4 progress observations if this is an MP4 download + if task is AVAssetDownloadTask { + cleanupMP4ProgressObservation(for: downloadID) + } + activeDownloads.removeAll { $0.id == downloadID } activeDownloadMap.removeValue(forKey: task) @@ -648,15 +655,15 @@ extension JSController { print("Created persistent download directory at \(persistentDir.path)") } - // Find any .movpkg files in the Documents directory + // Find any video files (.movpkg, .mp4) in the Documents directory let files = try fileManager.contentsOfDirectory(at: documentsDir, includingPropertiesForKeys: nil) - let movpkgFiles = files.filter { $0.pathExtension == "movpkg" } + let videoFiles = files.filter { ["movpkg", "mp4"].contains($0.pathExtension.lowercased()) } - if !movpkgFiles.isEmpty { - print("Found \(movpkgFiles.count) .movpkg files in Documents directory to migrate") + if !videoFiles.isEmpty { + print("Found \(videoFiles.count) video files in Documents directory to migrate") // Migrate each file - for fileURL in movpkgFiles { + for fileURL in videoFiles { let filename = fileURL.lastPathComponent let destinationURL = persistentDir.appendingPathComponent(filename) @@ -674,7 +681,7 @@ extension JSController { } } } else { - print("No .movpkg files found in Documents directory for migration") + print("No video files found in Documents directory for migration") } } catch { print("Error during migration: \(error.localizedDescription)") @@ -808,8 +815,8 @@ extension JSController { // Get all files in the directory let files = try fileManager.contentsOfDirectory(at: downloadDir, includingPropertiesForKeys: nil) - // Try to find a file that contains the asset name - for file in files where file.pathExtension == "movpkg" { + // Try to find a video file that contains the asset name + for file in files where ["movpkg", "mp4"].contains(file.pathExtension.lowercased()) { let filename = file.lastPathComponent // If the filename contains the asset name, it's likely our file @@ -1088,21 +1095,14 @@ extension JSController { return .notDownloaded } - /// Cancel a queued download that hasn't started yet + /// Cancel a queued download func cancelQueuedDownload(_ downloadID: UUID) { - // Remove from the download queue if it exists there - if let index = downloadQueue.firstIndex(where: { $0.id == downloadID }) { - let downloadTitle = downloadQueue[index].title ?? downloadQueue[index].originalURL.lastPathComponent - downloadQueue.remove(at: index) - - // Show notification - DropManager.shared.info("Download cancelled: \(downloadTitle)") - - // Notify observers of status change (no cache clearing needed for cancellation) - postDownloadNotification(.statusChange) - - print("Cancelled queued download: \(downloadTitle)") - } + downloadQueue.removeAll { $0.id == downloadID } + + // Notify of the cancellation + postDownloadNotification(.statusChange) + + print("Cancelled queued download: \(downloadID)") } /// Cancel an active download that is currently in progress @@ -1111,12 +1111,16 @@ extension JSController { cancelledDownloadIDs.insert(downloadID) // Find the active download and cancel its task - if let activeDownload = activeDownloads.first(where: { $0.id == downloadID }), - let task = activeDownload.task { + if let activeDownload = activeDownloads.first(where: { $0.id == downloadID }) { let downloadTitle = activeDownload.title ?? activeDownload.originalURL.lastPathComponent - // Cancel the actual download task - task.cancel() + if let task = activeDownload.task { + // M3U8 download - cancel AVAssetDownloadTask + task.cancel() + } else if let urlTask = activeDownload.urlSessionTask { + // MP4 download - cancel URLSessionDownloadTask + urlTask.cancel() + } // Show notification DropManager.shared.info("Download cancelled: \(downloadTitle)") @@ -1124,6 +1128,46 @@ extension JSController { print("Cancelled active download: \(downloadTitle)") } } + + /// Pause an MP4 download + func pauseMP4Download(_ downloadID: UUID) { + guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else { + print("MP4 Download not found for pausing: \(downloadID)") + return + } + + let download = activeDownloads[index] + guard let urlTask = download.urlSessionTask else { + print("No URL session task found for MP4 download: \(downloadID)") + return + } + + urlTask.suspend() + print("Paused MP4 download: \(download.title ?? download.originalURL.lastPathComponent)") + + // Notify UI of status change + postDownloadNotification(.statusChange) + } + + /// Resume an MP4 download + func resumeMP4Download(_ downloadID: UUID) { + guard let index = activeDownloads.firstIndex(where: { $0.id == downloadID }) else { + print("MP4 Download not found for resuming: \(downloadID)") + return + } + + let download = activeDownloads[index] + guard let urlTask = download.urlSessionTask else { + print("No URL session task found for MP4 download: \(downloadID)") + return + } + + urlTask.resume() + print("Resumed MP4 download: \(download.title ?? download.originalURL.lastPathComponent)") + + // Notify UI of status change + postDownloadNotification(.statusChange) + } } // MARK: - AVAssetDownloadDelegate @@ -1220,7 +1264,28 @@ extension JSController: AVAssetDownloadDelegate { let safeFilename = filename.replacingOccurrences(of: "/", with: "-") .replacingOccurrences(of: ":", with: "-") - let destinationURL = downloadDir.appendingPathComponent("\(safeFilename)-\(uniqueID).movpkg") + // Determine file extension based on the source location + let fileExtension: String + if location.pathExtension.isEmpty { + // If no extension from the source, check if it's likely an HLS download (which becomes .movpkg) + // or preserve original URL extension + if safeFilename.contains(".m3u8") || safeFilename.contains("hls") { + fileExtension = "movpkg" + print("Using .movpkg extension for HLS download: \(safeFilename)") + } else { + fileExtension = "mp4" // Default for direct video downloads + print("Using .mp4 extension for direct video download: \(safeFilename)") + } + } else { + // Use the extension from the downloaded file + let sourceExtension = location.pathExtension.lowercased() + fileExtension = (sourceExtension == "movpkg") ? "movpkg" : "mp4" + print("Using extension from source file: \(sourceExtension) -> \(fileExtension)") + } + + print("Final destination will be: \(safeFilename)-\(uniqueID).\(fileExtension)") + + let destinationURL = downloadDir.appendingPathComponent("\(safeFilename)-\(uniqueID).\(fileExtension)") // Move the file to the persistent location try fileManager.moveItem(at: location, to: destinationURL) @@ -1458,6 +1523,7 @@ struct JSActiveDownload: Identifiable, Equatable { let originalURL: URL var progress: Double let task: AVAssetDownloadTask? + let urlSessionTask: URLSessionDownloadTask? let type: DownloadType var metadata: AssetMetadata? var title: String? @@ -1468,6 +1534,22 @@ struct JSActiveDownload: Identifiable, Equatable { var headers: [String: String] var module: ScrapingModule? // Add module property to store ScrapingModule + // Computed property to get the current task state + var taskState: URLSessionTask.State { + if let avTask = task { + return avTask.state + } else if let urlTask = urlSessionTask { + return urlTask.state + } else { + return .suspended + } + } + + // Computed property to get the underlying task for control operations + var underlyingTask: URLSessionTask? { + return task ?? urlSessionTask + } + // Implement Equatable static func == (lhs: JSActiveDownload, rhs: JSActiveDownload) -> Bool { return lhs.id == rhs.id && @@ -1485,6 +1567,7 @@ struct JSActiveDownload: Identifiable, Equatable { originalURL: URL, progress: Double = 0, task: AVAssetDownloadTask? = nil, + urlSessionTask: URLSessionDownloadTask? = nil, queueStatus: DownloadQueueStatus = .queued, type: DownloadType = .movie, metadata: AssetMetadata? = nil, @@ -1499,6 +1582,7 @@ struct JSActiveDownload: Identifiable, Equatable { self.originalURL = originalURL self.progress = progress self.task = task + self.urlSessionTask = urlSessionTask self.type = type self.metadata = metadata self.title = title diff --git a/Sora/Utils/JSLoader/Downloads/JSController-StreamTypeDownload.swift b/Sora/Utils/JSLoader/Downloads/JSController-StreamTypeDownload.swift index 5ae5da0..5f8f741 100644 --- a/Sora/Utils/JSLoader/Downloads/JSController-StreamTypeDownload.swift +++ b/Sora/Utils/JSLoader/Downloads/JSController-StreamTypeDownload.swift @@ -70,12 +70,13 @@ extension JSController { url: url, headers: headers, title: title, - imageURL: imageURL ?? showPosterURL, + imageURL: imageURL, isEpisode: isEpisode, showTitle: showTitle, season: season, episode: episode, subtitleURL: subtitleURL, + showPosterURL: showPosterURL, completionHandler: completionHandler ) } diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index 5532b70..9483e0e 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -225,22 +225,47 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele fatalError("Invalid URL string") } - var request = URLRequest(url: url) - if let mydict = headers, !mydict.isEmpty { - for (key,value) in mydict { - request.addValue(value, forHTTPHeaderField: key) + let asset: AVURLAsset + + // Check if this is a local file URL + if url.scheme == "file" { + // For local files, don't add HTTP headers + Logger.shared.log("Loading local file: \(url.absoluteString)", type: "Debug") + + // Check if file exists + if FileManager.default.fileExists(atPath: url.path) { + Logger.shared.log("Local file exists at path: \(url.path)", type: "Debug") + } else { + Logger.shared.log("WARNING: Local file does not exist at path: \(url.path)", type: "Error") } + + asset = AVURLAsset(url: url) } else { - request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer") - request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin") + // For remote URLs, add HTTP headers + Logger.shared.log("Loading remote URL: \(url.absoluteString)", type: "Debug") + var request = URLRequest(url: url) + if let mydict = headers, !mydict.isEmpty { + for (key,value) in mydict { + request.addValue(value, forHTTPHeaderField: key) + } + } else { + request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer") + request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin") + } + + request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent") + + asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]]) } - request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent") - - let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]]) let playerItem = AVPlayerItem(asset: asset) self.player = AVPlayer(playerItem: playerItem) + // Add error observation + playerItem.addObserver(self, forKeyPath: "status", options: [.new], context: &playerItemKVOContext) + + Logger.shared.log("Created AVPlayerItem with status: \(playerItem.status.rawValue)", type: "Debug") + let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(fullUrl)") if lastPlayedTime > 0 { let seekTime = CMTime(seconds: lastPlayedTime, preferredTimescale: 1) @@ -446,6 +471,11 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele timeObserverToken = nil } + // Remove observer from player item if it exists + if let currentItem = player?.currentItem { + currentItem.removeObserver(self, forKeyPath: "status", context: &playerItemKVOContext) + } + player?.replaceCurrentItem(with: nil) player?.pause() player = nil @@ -495,7 +525,28 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele return } - if keyPath == "loadedTimeRanges" { + if keyPath == "status" { + if let playerItem = object as? AVPlayerItem { + switch playerItem.status { + case .readyToPlay: + Logger.shared.log("AVPlayerItem status: Ready to play", type: "Debug") + case .failed: + if let error = playerItem.error { + Logger.shared.log("AVPlayerItem failed with error: \(error.localizedDescription)", type: "Error") + if let nsError = error as NSError? { + Logger.shared.log("Error domain: \(nsError.domain), code: \(nsError.code), userInfo: \(nsError.userInfo)", type: "Error") + } + } else { + Logger.shared.log("AVPlayerItem failed with unknown error", type: "Error") + } + case .unknown: + Logger.shared.log("AVPlayerItem status: Unknown", type: "Debug") + @unknown default: + Logger.shared.log("AVPlayerItem status: Unknown default case", type: "Debug") + } + } + } else if keyPath == "loadedTimeRanges" { + // Handle loaded time ranges if needed } } @@ -2034,6 +2085,15 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private func parseM3U8(url: URL, completion: @escaping () -> Void) { + // For local file URLs, use a simple data task without custom headers + if url.scheme == "file" { + URLSession.shared.dataTask(with: url) { [weak self] data, response, error in + self?.processM3U8Data(data: data, url: url, completion: completion) + }.resume() + return + } + + // For remote URLs, add HTTP headers var request = URLRequest(url: url) if let mydict = headers, !mydict.isEmpty { for (key,value) in mydict { @@ -2046,77 +2106,80 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent") URLSession.shared.dataTask(with: request) { [weak self] data, response, error in - guard let self = self, - let data = data, - let content = String(data: data, encoding: .utf8) else { - Logger.shared.log("Failed to load m3u8 file") - DispatchQueue.main.async { - self?.qualities = [] - completion() - } - return + self?.processM3U8Data(data: data, url: url, completion: completion) + }.resume() + } + + private func processM3U8Data(data: Data?, url: URL, completion: @escaping () -> Void) { + guard let data = data, + let content = String(data: data, encoding: .utf8) else { + Logger.shared.log("Failed to load m3u8 file") + DispatchQueue.main.async { + self.qualities = [] + completion() } - - let lines = content.components(separatedBy: .newlines) - var qualities: [(String, String)] = [] - - qualities.append(("Auto (Recommended)", url.absoluteString)) - - func getQualityName(for height: Int) -> String { - switch height { - case 1080...: return "\(height)p (FHD)" - case 720..<1080: return "\(height)p (HD)" - case 480..<720: return "\(height)p (SD)" - default: return "\(height)p" - } + return + } + + let lines = content.components(separatedBy: .newlines) + var qualities: [(String, String)] = [] + + qualities.append(("Auto (Recommended)", url.absoluteString)) + + func getQualityName(for height: Int) -> String { + switch height { + case 1080...: return "\(height)p (FHD)" + case 720..<1080: return "\(height)p (HD)" + case 480..<720: return "\(height)p (SD)" + default: return "\(height)p" } - - for (index, line) in lines.enumerated() { - if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count { - if let resolutionRange = line.range(of: "RESOLUTION="), - let resolutionEndRange = line[resolutionRange.upperBound...].range(of: ",") - ?? line[resolutionRange.upperBound...].range(of: "\n") { + } + + for (index, line) in lines.enumerated() { + if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count { + if let resolutionRange = line.range(of: "RESOLUTION="), + let resolutionEndRange = line[resolutionRange.upperBound...].range(of: ",") + ?? line[resolutionRange.upperBound...].range(of: "\n") { + + let resolutionPart = String(line[resolutionRange.upperBound.. secondHeight - } - - if let auto = autoQuality { - sortedQualities.insert(auto, at: 0) - } - - self.qualities = sortedQualities - completion() + } + + DispatchQueue.main.async { + let autoQuality = qualities.first + var sortedQualities = qualities.dropFirst().sorted { first, second in + let firstHeight = Int(first.0.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0 + let secondHeight = Int(second.0.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0 + return firstHeight > secondHeight } - }.resume() + + if let auto = autoQuality { + sortedQualities.insert(auto, at: 0) + } + + self.qualities = sortedQualities + completion() + } } private func switchToQuality(urlString: String) { @@ -2126,20 +2189,48 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele let currentTime = player.currentTime() let wasPlaying = player.rate > 0 - var request = URLRequest(url: url) - if let mydict = headers, !mydict.isEmpty { - for (key,value) in mydict { - request.addValue(value, forHTTPHeaderField: key) - } - } else { - request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer") - request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin") - } - request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent") + let asset: AVURLAsset + + // Check if this is a local file URL + if url.scheme == "file" { + // For local files, don't add HTTP headers + Logger.shared.log("Switching to local file: \(url.absoluteString)", type: "Debug") + + // Check if file exists + if FileManager.default.fileExists(atPath: url.path) { + Logger.shared.log("Local file exists for quality switch: \(url.path)", type: "Debug") + } else { + Logger.shared.log("WARNING: Local file does not exist for quality switch: \(url.path)", type: "Error") + } + + asset = AVURLAsset(url: url) + } else { + // For remote URLs, add HTTP headers + Logger.shared.log("Switching to remote URL: \(url.absoluteString)", type: "Debug") + var request = URLRequest(url: url) + if let mydict = headers, !mydict.isEmpty { + for (key,value) in mydict { + request.addValue(value, forHTTPHeaderField: key) + } + } else { + request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Referer") + request.addValue("\(module.metadata.baseUrl)", forHTTPHeaderField: "Origin") + } + request.addValue("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", forHTTPHeaderField: "User-Agent") + + asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]]) + } - let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": request.allHTTPHeaderFields ?? [:]]) let playerItem = AVPlayerItem(asset: asset) + // Add observer for the new player item + playerItem.addObserver(self, forKeyPath: "status", options: [.new], context: &playerItemKVOContext) + + // Remove observer from old item if it exists + if let currentItem = player.currentItem { + currentItem.removeObserver(self, forKeyPath: "status", context: &playerItemKVOContext) + } + player.replaceCurrentItem(with: playerItem) player.seek(to: currentTime) if wasPlaying { diff --git a/Sora/Views/DownloadView.swift b/Sora/Views/DownloadView.swift index f8fe944..6871a74 100644 --- a/Sora/Views/DownloadView.swift +++ b/Sora/Views/DownloadView.swift @@ -240,39 +240,26 @@ struct DownloadView: View { metadataUrl: "" ) - if streamType == "mp4" { - let playerItem = AVPlayerItem(url: asset.localURL) - let player = AVPlayer(playerItem: playerItem) - let playerController = AVPlayerViewController() - playerController.player = player - - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootViewController = windowScene.windows.first?.rootViewController { - rootViewController.present(playerController, animated: true) { - player.play() - } - } - } else { - let customPlayer = CustomMediaPlayerViewController( - module: dummyModule, - urlString: asset.localURL.absoluteString, - fullUrl: asset.originalURL.absoluteString, - title: asset.metadata?.showTitle ?? asset.name, - episodeNumber: asset.metadata?.episode ?? 0, - onWatchNext: {}, - subtitlesURL: asset.localSubtitleURL?.absoluteString, - aniListID: 0, - totalEpisodes: asset.metadata?.episode ?? 0, - episodeImageUrl: asset.metadata?.posterURL?.absoluteString ?? "", - headers: nil - ) - - customPlayer.modalPresentationStyle = UIModalPresentationStyle.fullScreen - - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootViewController = windowScene.windows.first?.rootViewController { - rootViewController.present(customPlayer, animated: true) - } + // Always use CustomMediaPlayerViewController for consistency + let customPlayer = CustomMediaPlayerViewController( + module: dummyModule, + urlString: asset.localURL.absoluteString, + fullUrl: asset.originalURL.absoluteString, + title: asset.metadata?.showTitle ?? asset.name, + episodeNumber: asset.metadata?.episode ?? 0, + onWatchNext: {}, + subtitlesURL: asset.localSubtitleURL?.absoluteString, + aniListID: 0, + totalEpisodes: asset.metadata?.episode ?? 0, + episodeImageUrl: asset.metadata?.posterURL?.absoluteString ?? "", + headers: nil + ) + + customPlayer.modalPresentationStyle = UIModalPresentationStyle.fullScreen + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootViewController = windowScene.windows.first?.rootViewController { + rootViewController.present(customPlayer, animated: true) } } } @@ -733,7 +720,7 @@ struct EnhancedActiveDownloadCard: View { init(download: JSActiveDownload) { self.download = download _currentProgress = State(initialValue: download.progress) - _taskState = State(initialValue: download.task?.state ?? .suspended) + _taskState = State(initialValue: download.taskState) } var body: some View { @@ -868,18 +855,30 @@ struct EnhancedActiveDownloadCard: View { withAnimation(.easeInOut(duration: 0.1)) { currentProgress = currentDownload.progress } - if let task = currentDownload.task { - taskState = task.state - } + taskState = currentDownload.taskState } } private func toggleDownload() { if taskState == .running { - download.task?.suspend() + // Pause the download + if download.task != nil { + // M3U8 download - use AVAssetDownloadTask + download.underlyingTask?.suspend() + } else if download.urlSessionTask != nil { + // MP4 download - use dedicated method + JSController.shared.pauseMP4Download(download.id) + } taskState = .suspended } else if taskState == .suspended { - download.task?.resume() + // Resume the download + if download.task != nil { + // M3U8 download - use AVAssetDownloadTask + download.underlyingTask?.resume() + } else if download.urlSessionTask != nil { + // MP4 download - use dedicated method + JSController.shared.resumeMP4Download(download.id) + } taskState = .running } } From f3ade3e7b2bbfb790a2645d172fb18d2ab83b549 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Tue, 10 Jun 2025 12:10:04 +0200 Subject: [PATCH 02/52] fixed data view --- .../SettingsSubViews/SettingsViewData.swift | 51 +++++++------------ 1 file changed, 18 insertions(+), 33 deletions(-) diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift index 522a591..f48c194 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift @@ -158,20 +158,24 @@ struct SettingsViewData: View { ) { VStack(spacing: 0) { HStack { - Image(systemName: "folder.badge.gearshape") - .frame(width: 24, height: 24) - .foregroundStyle(.primary) - - Text("Current Cache Size") - .foregroundStyle(.primary) - - Spacer() - - if isCalculatingSize { - ProgressView() - .scaleEffect(0.7) - .padding(.trailing, 5) + Button(action: { + activeAlert = .clearCache + showAlert = true + }) { + HStack { + Image(systemName: "trash") + .frame(width: 24, height: 24) + .foregroundStyle(.red) + + Text("Clear All Caches") + .foregroundStyle(.red) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) } + .buttonStyle(PlainButtonStyle()) Text(cacheSizeText) .foregroundStyle(.gray) @@ -179,30 +183,11 @@ struct SettingsViewData: View { .padding(.horizontal, 16) .padding(.vertical, 12) - Button(action: { - activeAlert = .clearCache - showAlert = true - }) { - HStack { - Image(systemName: "trash") - .frame(width: 24, height: 24) - .foregroundStyle(.red) - - Text("Clear All Caches") - .foregroundStyle(.red) - - Spacer() - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - } - .buttonStyle(PlainButtonStyle()) - Divider().padding(.horizontal, 16) SettingsButtonRow( icon: "film", - title: "Remove Downloaded Media", + title: "Remove Downloads", subtitle: formatSize(downloadsSize), action: { activeAlert = .removeDownloads From c6e704c04d818428522303174b1590686e386dc3 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Tue, 10 Jun 2025 15:32:15 +0200 Subject: [PATCH 03/52] revert poster --- Sora/Views/MediaInfoView/MediaInfoView.swift | 24 +++++++++++++++---- .../SettingsSubViews/SettingsViewData.swift | 10 ++++---- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index eb1170e..883788d 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -494,10 +494,21 @@ struct MediaInfoView: View { } } - Button(action: { - fetchTMDBPosterImageAndSet() - }) { - Label("Use TMDB Poster Image", systemImage: "photo") + if UserDefaults.standard.string(forKey: "originalPoster_\(href)") != nil { + Button(action: { + if let originalPoster = UserDefaults.standard.string(forKey: "originalPoster_\(href)") { + imageUrl = originalPoster + UserDefaults.standard.removeObject(forKey: "tmdbPosterURL_\(href)") + } + }) { + Label("Revert Module Poster", systemImage: "photo.badge.arrow.down") + } + } else { + Button(action: { + fetchTMDBPosterImageAndSet() + }) { + Label("Use TMDB Poster Image", systemImage: "photo") + } } Divider() @@ -841,6 +852,9 @@ struct MediaInfoView: View { imageUrl = "https://image.tmdb.org/t/p/w\(tmdbImageWidth)\(posterPath)" } DispatchQueue.main.async { + let currentPosterKey = "originalPoster_\(self.href)" + let currentPoster = self.imageUrl + UserDefaults.standard.set(currentPoster, forKey: currentPosterKey) self.imageUrl = imageUrl UserDefaults.standard.set(imageUrl, forKey: "tmdbPosterURL_\(self.href)") } @@ -1216,7 +1230,7 @@ struct MediaInfoView: View { guard self.activeFetchID == fetchID else { return } - self.playStream(url: streamUrl, fullURL: fullURL, subtitles: subtitles, headers: headers, fetchID: fetchID) + self.playStream(url: streamUrl, fullURL: href, subtitles: subtitles, headers: headers, fetchID: fetchID) }) streamIndex += 1 diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift index f48c194..99bd701 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift @@ -137,7 +137,7 @@ fileprivate struct SettingsButtonRow: View { struct SettingsViewData: View { @State private var showAlert = false - @State private var cacheSizeText: String = "Calculating..." + @State private var cacheSizeText: String = "..." @State private var isCalculatingSize: Bool = false @State private var cacheSize: Int64 = 0 @State private var documentsSize: Int64 = 0 @@ -167,7 +167,7 @@ struct SettingsViewData: View { .frame(width: 24, height: 24) .foregroundStyle(.red) - Text("Clear All Caches") + Text("Remove All Caches") .foregroundStyle(.red) Spacer() @@ -199,7 +199,7 @@ struct SettingsViewData: View { SettingsButtonRow( icon: "doc.text", - title: "Remove All Files in Documents", + title: "Remove All Documents", subtitle: formatSize(documentsSize), action: { activeAlert = .removeDocs @@ -271,7 +271,7 @@ struct SettingsViewData: View { func calculateCacheSize() { isCalculatingSize = true - cacheSizeText = "Calculating..." + cacheSizeText = "..." DispatchQueue.global(qos: .background).async { if let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first { @@ -283,7 +283,7 @@ struct SettingsViewData: View { } } else { DispatchQueue.main.async { - self.cacheSizeText = "Unknown" + self.cacheSizeText = "N/A" self.isCalculatingSize = false } } From 66353bc80cbc731e20bcef63832ba81d0853ab28 Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Tue, 10 Jun 2025 17:10:23 +0200 Subject: [PATCH 04/52] toggle for subtitles (#166) * added persistance for subtitle toggling * fixed typo * now we good (i think fuck windows) * adasd WSEFG --------- Co-authored-by: cranci <100066266+cranci1@users.noreply.github.com> --- .../MediaPlayer/CustomPlayer/CustomPlayer.swift | 1 + .../Helpers/SubtitleSettingsManager.swift | 1 + .../SettingsSubViews/SettingsViewPlayer.swift | 17 +++++++++++++++-- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index 9483e0e..fcf4bf2 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -307,6 +307,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele setupPipIfSupported() view.bringSubviewToFront(subtitleStackView) + subtitleStackView.isHidden = !SubtitleSettingsManager.shared.settings.enabled AniListMutation().fetchMalID(animeId: aniListID) { [weak self] result in switch result { diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/SubtitleSettingsManager.swift b/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/SubtitleSettingsManager.swift index 57e96b3..2ef7fbd 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/SubtitleSettingsManager.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/SubtitleSettingsManager.swift @@ -8,6 +8,7 @@ import UIKit struct SubtitleSettings: Codable { + var enabled: Bool = true var foregroundColor: String = "white" var fontSize: Double = 20.0 var shadowRadius: Double = 1.0 diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift index 0c57c37..9ce67eb 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift @@ -342,12 +342,25 @@ struct SubtitleSettingsSection: View { @State private var backgroundEnabled: Bool = SubtitleSettingsManager.shared.settings.backgroundEnabled @State private var bottomPadding: Double = Double(SubtitleSettingsManager.shared.settings.bottomPadding) @State private var subtitleDelay: Double = SubtitleSettingsManager.shared.settings.subtitleDelay + @AppStorage("subtitlesEnabled") private var subtitlesEnabled: Bool = true private let colors = ["white", "yellow", "green", "blue", "red", "purple"] private let shadowOptions = [0, 1, 3, 6] var body: some View { - SettingsSection(title: "Subtitle Settings") { + SettingsSection(title: "Subtitle Settings") { + SettingsToggleRow( + icon: "captions.bubble", + title: "Enable Subtitles", + isOn: $subtitlesEnabled, + showDivider: false + ) + .onChange(of: subtitlesEnabled) { newValue in + SubtitleSettingsManager.shared.update { settings in + settings.enabled = newValue + } + } + SettingsPickerRow( icon: "paintbrush", title: "Subtitle Color", @@ -416,4 +429,4 @@ struct SubtitleSettingsSection: View { } } } -} +} \ No newline at end of file From fd9e68513d614f2f5e1cce0186f5603c1c814ec2 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Tue, 10 Jun 2025 20:36:42 +0200 Subject: [PATCH 05/52] ops --- .../SettingsSubViews/SettingsViewData.swift | 30 ++++--------------- 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift index 99bd701..efffd95 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift @@ -157,33 +157,15 @@ struct SettingsViewData: View { footer: "The app cache allow the app to sho immages faster.\n\nClearing the documents folder will remove all the modules.\n\nThe App Data should never be erased if you don't know what that will cause." ) { VStack(spacing: 0) { - HStack { - Button(action: { + SettingsButtonRow( + icon: "trash", + title: "Remove All Cache", + subtitle: cacheSizeText, + action: { activeAlert = .clearCache showAlert = true - }) { - HStack { - Image(systemName: "trash") - .frame(width: 24, height: 24) - .foregroundStyle(.red) - - Text("Remove All Caches") - .foregroundStyle(.red) - - Spacer() - } - .padding(.horizontal, 16) - .padding(.vertical, 12) } - .buttonStyle(PlainButtonStyle()) - - Text(cacheSizeText) - .foregroundStyle(.gray) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - - Divider().padding(.horizontal, 16) + ) SettingsButtonRow( icon: "film", From 440ec57d592900765ac52978a8349e485e1bc0fe Mon Sep 17 00:00:00 2001 From: 50/50 <80717571+50n50@users.noreply.github.com> Date: Tue, 10 Jun 2025 20:37:54 +0200 Subject: [PATCH 06/52] Fixes, read description (#168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fixed episodecells getting stuck sliding * Enabled device scaling for ipad not good enough yet, not applied everywhere cuz idk where to apply exactly 💯 * Fixed blur in continue watching cells * Keyboard controls player * fixed downloadview buttons * Reduced tab bar outline opacity --------- Co-authored-by: cranci <100066266+cranci1@users.noreply.github.com> --- Sora/Localizable.xcstrings | 31 +++++++++-- .../CustomPlayer/CustomPlayer.swift | 51 +++++++++++++++++++ Sora/Utils/TabBar/TabBar.swift | 2 +- Sora/Views/DownloadView.swift | 47 +++++------------ Sora/Views/LibraryView/LibraryView.swift | 2 - .../xcshareddata/swiftpm/Package.resolved | 3 +- 6 files changed, 92 insertions(+), 44 deletions(-) diff --git a/Sora/Localizable.xcstrings b/Sora/Localizable.xcstrings index d66b847..1b90ac5 100644 --- a/Sora/Localizable.xcstrings +++ b/Sora/Localizable.xcstrings @@ -71,6 +71,9 @@ }, "App Data" : { + }, + "Are you sure you want to clear all cached data? This will help free up storage space." : { + }, "Are you sure you want to delete '%@'?" : { @@ -90,6 +93,9 @@ }, "Are you sure you want to erase all app data? This action cannot be undone." : { + }, + "Are you sure you want to remove all downloaded media files (.mov, .mp4, .pkg)? This action cannot be undone." : { + }, "Are you sure you want to remove all files in the Documents folder? This will remove all modules." : { @@ -112,10 +118,10 @@ "Clear" : { }, - "Clear All Caches" : { + "Clear All Downloads" : { }, - "Clear All Downloads" : { + "Clear Cache" : { }, "Clear Library Only" : { @@ -138,9 +144,6 @@ }, "cranci1" : { - }, - "Current Cache Size" : { - }, "DATA/LOGS" : { @@ -255,6 +258,9 @@ }, "Mark as Watched" : { + }, + "Mark watched" : { + }, "Match with AniList" : { @@ -333,9 +339,15 @@ }, "Remove" : { + }, + "Remove All Caches" : { + }, "Remove Documents" : { + }, + "Remove Downloaded Media" : { + }, "Remove from Bookmarks" : { @@ -348,9 +360,15 @@ }, "Reset AniList ID" : { + }, + "Reset progress" : { + }, "Reset Progress" : { + }, + "Revert Module Poster" : { + }, "Running Sora %@ - cranci1" : { @@ -372,6 +390,9 @@ }, "Season %lld" : { + }, + "Segments Color" : { + }, "Select Module" : { diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index fcf4bf2..2995830 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -2701,6 +2701,57 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele default: return .white } } + + override var canBecomeFirstResponder: Bool { + return true + } + + override var keyCommands: [UIKeyCommand]? { + return [ + UIKeyCommand(input: " ", modifierFlags: [], action: #selector(handleSpaceKey), discoverabilityTitle: "Play/Pause"), + UIKeyCommand(input: UIKeyCommand.inputLeftArrow, modifierFlags: [], action: #selector(handleLeftArrow), discoverabilityTitle: "Seek Backward 10s"), + UIKeyCommand(input: UIKeyCommand.inputRightArrow, modifierFlags: [], action: #selector(handleRightArrow), discoverabilityTitle: "Seek Forward 10s"), + UIKeyCommand(input: UIKeyCommand.inputUpArrow, modifierFlags: [], action: #selector(handleUpArrow), discoverabilityTitle: "Seek Forward 60s"), + UIKeyCommand(input: UIKeyCommand.inputDownArrow, modifierFlags: [], action: #selector(handleDownArrow), discoverabilityTitle: "Seek Backward 60s"), + UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(handleEscape), discoverabilityTitle: "Dismiss Player") + ] + } + + @objc private func handleSpaceKey() { + togglePlayPause() + } + + @objc private func handleLeftArrow() { + let skipValue = 10.0 + currentTimeVal = max(currentTimeVal - skipValue, 0) + player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) + animateButtonRotation(backwardButton, clockwise: false) + } + + @objc private func handleRightArrow() { + let skipValue = 10.0 + currentTimeVal = min(currentTimeVal + skipValue, duration) + player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) + animateButtonRotation(forwardButton) + } + + @objc private func handleUpArrow() { + let skipValue = 60.0 + currentTimeVal = min(currentTimeVal + skipValue, duration) + player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) + animateButtonRotation(forwardButton) + } + + @objc private func handleDownArrow() { + let skipValue = 60.0 + currentTimeVal = max(currentTimeVal - skipValue, 0) + player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) + animateButtonRotation(backwardButton, clockwise: false) + } + + @objc private func handleEscape() { + dismiss(animated: true, completion: nil) + } } class GradientOverlayButton: UIButton { diff --git a/Sora/Utils/TabBar/TabBar.swift b/Sora/Utils/TabBar/TabBar.swift index a646e58..ef3758c 100644 --- a/Sora/Utils/TabBar/TabBar.swift +++ b/Sora/Utils/TabBar/TabBar.swift @@ -90,7 +90,7 @@ struct TabBar: View { .stroke( LinearGradient( gradient: Gradient(stops: [ - .init(color: Color.accentColor.opacity(gradientOpacity), location: 0), + .init(color: Color.accentColor.opacity(0.25), location: 0), .init(color: Color.accentColor.opacity(0), location: 1) ]), startPoint: .top, diff --git a/Sora/Views/DownloadView.swift b/Sora/Views/DownloadView.swift index 6871a74..31afe1b 100644 --- a/Sora/Views/DownloadView.swift +++ b/Sora/Views/DownloadView.swift @@ -293,29 +293,15 @@ struct CustomDownloadHeader: View { Image(systemName: isSearchActive ? "xmark.circle.fill" : "magnifyingglass") .resizable() .scaledToFit() - .frame(width: 24, height: 24) + .frame(width: 18, height: 18) .foregroundColor(.accentColor) - .padding(6) + .padding(10) .background( Circle() .fill(Color.gray.opacity(0.2)) .shadow(color: .accentColor.opacity(0.2), radius: 2) ) - .overlay( - Circle() - .stroke( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color.accentColor.opacity(0.25), location: 0), - .init(color: Color.accentColor.opacity(0), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.5 - ) - .frame(width: 32, height: 32) - ) + .circularGradientOutline() } if showSortMenu { @@ -336,28 +322,15 @@ struct CustomDownloadHeader: View { Image(systemName: "arrow.up.arrow.down") .resizable() .scaledToFit() - .frame(width: 24, height: 24) + .frame(width: 18, height: 18) .foregroundColor(.accentColor) - .padding(6) + .padding(10) .background( Circle() .fill(Color.gray.opacity(0.2)) .shadow(color: .accentColor.opacity(0.2), radius: 2) ) - .overlay( - Circle() - .stroke( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color.accentColor.opacity(0.25), location: 0), - .init(color: Color.accentColor.opacity(0), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.5 - ) - ) + .circularGradientOutline() } } } @@ -370,8 +343,10 @@ struct CustomDownloadHeader: View { HStack(spacing: 12) { HStack(spacing: 12) { Image(systemName: "magnifyingglass") + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) .foregroundColor(.secondary) - .font(.body) TextField("Search downloads", text: $searchText) .textFieldStyle(PlainTextFieldStyle()) @@ -382,8 +357,10 @@ struct CustomDownloadHeader: View { searchText = "" }) { Image(systemName: "xmark.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) .foregroundColor(.secondary) - .font(.body) } } } diff --git a/Sora/Views/LibraryView/LibraryView.swift b/Sora/Views/LibraryView/LibraryView.swift index 1aa5829..b25ae8e 100644 --- a/Sora/Views/LibraryView/LibraryView.swift +++ b/Sora/Views/LibraryView/LibraryView.swift @@ -303,8 +303,6 @@ struct ContinueWatchingCell: View { } .overlay( ZStack { - ProgressiveBlurView() - .cornerRadius(10, corners: [.bottomLeft, .bottomRight]) VStack(alignment: .leading, spacing: 4) { Spacer() diff --git a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0b5a161..d8a331d 100644 --- a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "e12f82ce5205016ea66a114308acd41450cfe950ccb1aacfe0e26181d2036fa4", "pins" : [ { "identity" : "drops", @@ -28,5 +29,5 @@ } } ], - "version" : 2 + "version" : 3 } From cc4c75f88af2c28bc97f6314efd50a96e303d57d Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Tue, 10 Jun 2025 21:06:42 +0200 Subject: [PATCH 07/52] let's text @bshar1865 code Co-Authored-By: Bshar Esfky <98615778+bshar1865@users.noreply.github.com> --- Sora/Utils/MediaPlayer/SubtitleManager.swift | 81 ++++++++++++++++++++ Sora/Utils/MediaPlayer/VideoPlayer.swift | 40 +++++++++- 2 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 Sora/Utils/MediaPlayer/SubtitleManager.swift diff --git a/Sora/Utils/MediaPlayer/SubtitleManager.swift b/Sora/Utils/MediaPlayer/SubtitleManager.swift new file mode 100644 index 0000000..d0cdc7b --- /dev/null +++ b/Sora/Utils/MediaPlayer/SubtitleManager.swift @@ -0,0 +1,81 @@ +// +// SubtitleManager.swift +// Sora +// +// Created by Francesco on 10/06/25. +// + +import UIKit +import Foundation +import AVFoundation + +class SubtitleManager { + static let shared = SubtitleManager() + private let subtitleLoader = VTTSubtitlesLoader() + + private init() {} + + func loadSubtitles(from url: URL) async throws -> [SubtitleCue] { + return await withCheckedContinuation { continuation in + subtitleLoader.load(from: url.absoluteString) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + continuation.resume(returning: self.subtitleLoader.cues) + } + } + } + + func createSubtitleOverlay(for cues: [SubtitleCue], player: AVPlayer) -> SubtitleOverlayView { + let overlay = SubtitleOverlayView() + let interval = CMTime(seconds: 0.1, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) + + player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { time in + let currentTime = time.seconds + let currentCue = cues.first { cue in + currentTime >= cue.startTime && currentTime <= cue.endTime + } + overlay.update(with: currentCue?.text ?? "") + } + + return overlay + } +} + +class SubtitleOverlayView: UIView { + private let label: UILabel = { + let label = UILabel() + label.numberOfLines = 0 + label.textAlignment = .center + label.textColor = .white + label.font = .systemFont(ofSize: 16, weight: .medium) + label.layer.shadowColor = UIColor.black.cgColor + label.layer.shadowOffset = CGSize(width: 1, height: 1) + label.layer.shadowOpacity = 0.8 + label.layer.shadowRadius = 2 + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + setupView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupView() + } + + private func setupView() { + backgroundColor = .clear + addSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20), + label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20), + label.centerYAnchor.constraint(equalTo: centerYAnchor) + ]) + } + + func update(with text: String) { + label.text = text + } +} diff --git a/Sora/Utils/MediaPlayer/VideoPlayer.swift b/Sora/Utils/MediaPlayer/VideoPlayer.swift index 58a9a65..384153c 100644 --- a/Sora/Utils/MediaPlayer/VideoPlayer.swift +++ b/Sora/Utils/MediaPlayer/VideoPlayer.swift @@ -24,6 +24,7 @@ class VideoPlayerViewController: UIViewController { var episodeNumber: Int = 0 var episodeImageUrl: String = "" var mediaTitle: String = "" + var subtitleOverlay: SubtitleOverlayView? init(module: ScrapingModule) { self.module = module @@ -66,6 +67,24 @@ class VideoPlayerViewController: UIViewController { playerViewController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] view.addSubview(playerViewController.view) playerViewController.didMove(toParent: self) + + if !subtitles.isEmpty && UserDefaults.standard.bool(forKey: "subtitlesEnabled") { + if let subtitleURL = URL(string: subtitles) { + Task { + do { + let subtitleCues = try await SubtitleManager.shared.loadSubtitles(from: subtitleURL) + await MainActor.run { + if let player = self.player { + let overlay = SubtitleManager.shared.createSubtitleOverlay(for: subtitleCues, player: player) + self.addSubtitleOverlay(overlay) + } + } + } catch { + Logger.shared.log("Failed to load subtitles: \(error.localizedDescription)", type: "Error") + } + } + } + } } addPeriodicTimeObserver(fullURL: fullUrl) @@ -113,8 +132,8 @@ class VideoPlayerViewController: UIViewController { guard let self = self, let currentItem = player.currentItem, currentItem.duration.seconds.isFinite else { - return - } + return + } let currentTime = time.seconds let duration = currentItem.duration.seconds @@ -158,6 +177,22 @@ class VideoPlayerViewController: UIViewController { } } + private func addSubtitleOverlay(_ overlay: SubtitleOverlayView) { + subtitleOverlay?.removeFromSuperview() + subtitleOverlay = overlay + + guard let playerView = playerViewController?.view else { return } + playerView.addSubview(overlay) + overlay.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + overlay.leadingAnchor.constraint(equalTo: playerView.leadingAnchor), + overlay.trailingAnchor.constraint(equalTo: playerView.trailingAnchor), + overlay.bottomAnchor.constraint(equalTo: playerView.bottomAnchor), + overlay.heightAnchor.constraint(equalToConstant: 100) + ]) + } + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { if UserDefaults.standard.bool(forKey: "alwaysLandscape") { return .landscape @@ -179,5 +214,6 @@ class VideoPlayerViewController: UIViewController { if let timeObserverToken = timeObserverToken { player?.removeTimeObserver(timeObserverToken) } + subtitleOverlay?.removeFromSuperview() } } From 7c5cec2285d36e0f2b23782553868b43a2ba59e3 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Tue, 10 Jun 2025 21:15:16 +0200 Subject: [PATCH 08/52] opsi my fault --- Sora/Utils/MediaPlayer/SubtitleManager.swift | 81 ----------------- Sora/Utils/MediaPlayer/VideoPlayer.swift | 87 ++++++++++++------- .../xcshareddata/swiftpm/Package.resolved | 3 +- 3 files changed, 55 insertions(+), 116 deletions(-) delete mode 100644 Sora/Utils/MediaPlayer/SubtitleManager.swift diff --git a/Sora/Utils/MediaPlayer/SubtitleManager.swift b/Sora/Utils/MediaPlayer/SubtitleManager.swift deleted file mode 100644 index d0cdc7b..0000000 --- a/Sora/Utils/MediaPlayer/SubtitleManager.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// SubtitleManager.swift -// Sora -// -// Created by Francesco on 10/06/25. -// - -import UIKit -import Foundation -import AVFoundation - -class SubtitleManager { - static let shared = SubtitleManager() - private let subtitleLoader = VTTSubtitlesLoader() - - private init() {} - - func loadSubtitles(from url: URL) async throws -> [SubtitleCue] { - return await withCheckedContinuation { continuation in - subtitleLoader.load(from: url.absoluteString) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - continuation.resume(returning: self.subtitleLoader.cues) - } - } - } - - func createSubtitleOverlay(for cues: [SubtitleCue], player: AVPlayer) -> SubtitleOverlayView { - let overlay = SubtitleOverlayView() - let interval = CMTime(seconds: 0.1, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) - - player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { time in - let currentTime = time.seconds - let currentCue = cues.first { cue in - currentTime >= cue.startTime && currentTime <= cue.endTime - } - overlay.update(with: currentCue?.text ?? "") - } - - return overlay - } -} - -class SubtitleOverlayView: UIView { - private let label: UILabel = { - let label = UILabel() - label.numberOfLines = 0 - label.textAlignment = .center - label.textColor = .white - label.font = .systemFont(ofSize: 16, weight: .medium) - label.layer.shadowColor = UIColor.black.cgColor - label.layer.shadowOffset = CGSize(width: 1, height: 1) - label.layer.shadowOpacity = 0.8 - label.layer.shadowRadius = 2 - return label - }() - - override init(frame: CGRect) { - super.init(frame: frame) - setupView() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - setupView() - } - - private func setupView() { - backgroundColor = .clear - addSubview(label) - label.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 20), - label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -20), - label.centerYAnchor.constraint(equalTo: centerYAnchor) - ]) - } - - func update(with text: String) { - label.text = text - } -} diff --git a/Sora/Utils/MediaPlayer/VideoPlayer.swift b/Sora/Utils/MediaPlayer/VideoPlayer.swift index 384153c..8da1cd0 100644 --- a/Sora/Utils/MediaPlayer/VideoPlayer.swift +++ b/Sora/Utils/MediaPlayer/VideoPlayer.swift @@ -24,7 +24,8 @@ class VideoPlayerViewController: UIViewController { var episodeNumber: Int = 0 var episodeImageUrl: String = "" var mediaTitle: String = "" - var subtitleOverlay: SubtitleOverlayView? + var subtitlesLoader: VTTSubtitlesLoader? + var subtitleLabel: UILabel? init(module: ScrapingModule) { self.module = module @@ -35,6 +36,54 @@ class VideoPlayerViewController: UIViewController { fatalError("init(coder:) has not been implemented") } + private func setupSubtitles() { + guard !subtitles.isEmpty && UserDefaults.standard.bool(forKey: "subtitlesEnabled"), + let subtitleURL = URL(string: subtitles) else { + return + } + + subtitlesLoader = VTTSubtitlesLoader() + setupSubtitleLabel() + + subtitlesLoader?.load(from: subtitles) + + let interval = CMTime(seconds: 0.1, preferredTimescale: CMTimeScale(NSEC_PER_SEC)) + player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in + self?.updateSubtitles(at: time.seconds) + } + } + + private func setupSubtitleLabel() { + let label = UILabel() + label.numberOfLines = 0 + label.textAlignment = .center + label.textColor = .white + label.font = .systemFont(ofSize: 16, weight: .medium) + label.layer.shadowColor = UIColor.black.cgColor + label.layer.shadowOffset = CGSize(width: 1, height: 1) + label.layer.shadowOpacity = 0.8 + label.layer.shadowRadius = 2 + + guard let playerView = playerViewController?.view else { return } + playerView.addSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + label.leadingAnchor.constraint(equalTo: playerView.leadingAnchor, constant: 16), + label.trailingAnchor.constraint(equalTo: playerView.trailingAnchor, constant: -16), + label.bottomAnchor.constraint(equalTo: playerView.bottomAnchor, constant: -32) + ]) + + self.subtitleLabel = label + } + + private func updateSubtitles(at time: Double) { + let currentSubtitle = subtitlesLoader?.cues.first { cue in + time >= cue.startTime && time <= cue.endTime + } + subtitleLabel?.text = currentSubtitle?.text ?? "" + } + override func viewDidLoad() { super.viewDidLoad() @@ -69,21 +118,7 @@ class VideoPlayerViewController: UIViewController { playerViewController.didMove(toParent: self) if !subtitles.isEmpty && UserDefaults.standard.bool(forKey: "subtitlesEnabled") { - if let subtitleURL = URL(string: subtitles) { - Task { - do { - let subtitleCues = try await SubtitleManager.shared.loadSubtitles(from: subtitleURL) - await MainActor.run { - if let player = self.player { - let overlay = SubtitleManager.shared.createSubtitleOverlay(for: subtitleCues, player: player) - self.addSubtitleOverlay(overlay) - } - } - } catch { - Logger.shared.log("Failed to load subtitles: \(error.localizedDescription)", type: "Error") - } - } - } + setupSubtitles() } } @@ -177,22 +212,6 @@ class VideoPlayerViewController: UIViewController { } } - private func addSubtitleOverlay(_ overlay: SubtitleOverlayView) { - subtitleOverlay?.removeFromSuperview() - subtitleOverlay = overlay - - guard let playerView = playerViewController?.view else { return } - playerView.addSubview(overlay) - overlay.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - overlay.leadingAnchor.constraint(equalTo: playerView.leadingAnchor), - overlay.trailingAnchor.constraint(equalTo: playerView.trailingAnchor), - overlay.bottomAnchor.constraint(equalTo: playerView.bottomAnchor), - overlay.heightAnchor.constraint(equalToConstant: 100) - ]) - } - override var supportedInterfaceOrientations: UIInterfaceOrientationMask { if UserDefaults.standard.bool(forKey: "alwaysLandscape") { return .landscape @@ -214,6 +233,8 @@ class VideoPlayerViewController: UIViewController { if let timeObserverToken = timeObserverToken { player?.removeTimeObserver(timeObserverToken) } - subtitleOverlay?.removeFromSuperview() + subtitleLabel?.removeFromSuperview() + subtitleLabel = nil + subtitlesLoader = nil } } diff --git a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d8a331d..0b5a161 100644 --- a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,4 @@ { - "originHash" : "e12f82ce5205016ea66a114308acd41450cfe950ccb1aacfe0e26181d2036fa4", "pins" : [ { "identity" : "drops", @@ -29,5 +28,5 @@ } } ], - "version" : 3 + "version" : 2 } From e4e5fc520a65151fd16d5ff14b4ea271956a7642 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Wed, 11 Jun 2025 09:56:02 +0200 Subject: [PATCH 09/52] little tweaks --- Sora/Utils/MediaPlayer/VideoPlayer.swift | 6 ++++-- .../SettingsView/SettingsSubViews/SettingsViewData.swift | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Sora/Utils/MediaPlayer/VideoPlayer.swift b/Sora/Utils/MediaPlayer/VideoPlayer.swift index 8da1cd0..cc61a8e 100644 --- a/Sora/Utils/MediaPlayer/VideoPlayer.swift +++ b/Sora/Utils/MediaPlayer/VideoPlayer.swift @@ -30,6 +30,9 @@ class VideoPlayerViewController: UIViewController { init(module: ScrapingModule) { self.module = module super.init(nibName: nil, bundle: nil) + if UserDefaults.standard.object(forKey: "subtitlesEnabled") == nil { + UserDefaults.standard.set(true, forKey: "subtitlesEnabled") + } } required init?(coder: NSCoder) { @@ -37,8 +40,7 @@ class VideoPlayerViewController: UIViewController { } private func setupSubtitles() { - guard !subtitles.isEmpty && UserDefaults.standard.bool(forKey: "subtitlesEnabled"), - let subtitleURL = URL(string: subtitles) else { + guard !subtitles.isEmpty, UserDefaults.standard.bool(forKey: "subtitlesEnabled"), let subtitleURL = URL(string: subtitles) else { return } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift index efffd95..c77f327 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift @@ -167,6 +167,8 @@ struct SettingsViewData: View { } ) + Divider().padding(.horizontal, 16) + SettingsButtonRow( icon: "film", title: "Remove Downloads", From 1a4b78e64e276662473afe87518bc1f29cb28bef Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Wed, 11 Jun 2025 10:02:19 +0200 Subject: [PATCH 10/52] Fixed poster revertion --- Sora/Views/MediaInfoView/MediaInfoView.swift | 3 ++- Sulfur.xcodeproj/project.pbxproj | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 883788d..c8bc411 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -499,9 +499,10 @@ struct MediaInfoView: View { if let originalPoster = UserDefaults.standard.string(forKey: "originalPoster_\(href)") { imageUrl = originalPoster UserDefaults.standard.removeObject(forKey: "tmdbPosterURL_\(href)") + UserDefaults.standard.removeObject(forKey: "originalPoster_\(href)") } }) { - Label("Revert Module Poster", systemImage: "photo.badge.arrow.down") + Label("Original Poster", systemImage: "photo.badge.arrow.down") } } else { Button(action: { diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 9437d43..7c33e67 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -353,8 +353,8 @@ isa = PBXGroup; children = ( 138AA1B52D2D66EC0021F9DF /* EpisodeCell */, - 1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */, 133D7C802D2BE2630075467E /* MediaInfoView.swift */, + 1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */, ); path = MediaInfoView; sourceTree = ""; From 175f011d01e4b2fe6de77335cb5b8ec59dc1ce73 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Wed, 11 Jun 2025 10:36:48 +0200 Subject: [PATCH 11/52] Let's test Trakt Updates --- .../Trakt/Mutations/TraktPushUpdates.swift | 3 - .../CustomPlayer/CustomPlayer.swift | 79 +++++++++++++------ Sora/Utils/MediaPlayer/VideoPlayer.swift | 51 +++++++++--- Sora/Views/MediaInfoView/MediaInfoView.swift | 2 + 4 files changed, 99 insertions(+), 36 deletions(-) diff --git a/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift b/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift index 2108e9c..1d5dd3b 100644 --- a/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift +++ b/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift @@ -31,13 +31,10 @@ class TraktMutation { } enum ExternalIDType { - case imdb(String) case tmdb(Int) var dictionary: [String: Any] { switch self { - case .imdb(let id): - return ["imdb": id] case .tmdb(let id): return ["tmdb": id] } diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index 2995830..c0d7e0e 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -24,6 +24,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele let onWatchNext: () -> Void let aniListID: Int var headers: [String:String]? = nil + var tmdbID: Int? = nil + var isMovie: Bool = false + var seasonNumber: Int = 1 private var aniListUpdatedSuccessfully = false private var aniListUpdateImpossible: Bool = false @@ -1644,12 +1647,39 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele let remainingPercentage = (self.duration - self.currentTimeVal) / self.duration - if remainingPercentage < 0.1 && - self.aniListID != 0 && - !self.aniListUpdatedSuccessfully && - !self.aniListUpdateImpossible - { - self.tryAniListUpdate() + if remainingPercentage < 0.1 { + if self.aniListID != 0 && !self.aniListUpdatedSuccessfully && !self.aniListUpdateImpossible { + self.tryAniListUpdate() + } + + if let tmdbId = self.tmdbID { + let traktMutation = TraktMutation() + + if self.isMovie { + traktMutation.markAsWatched(type: "movie", externalID: .tmdb(tmdbId)) { result in + switch result { + case .success: + Logger.shared.log("Successfully updated Trakt progress for movie", type: "General") + case .failure(let error): + Logger.shared.log("Failed to update Trakt progress: \(error.localizedDescription)", type: "Error") + } + } + } else { + traktMutation.markAsWatched( + type: "episode", + externalID: .tmdb(tmdbId), + episodeNumber: self.episodeNumber, + seasonNumber: self.seasonNumber + ) { result in + switch result { + case .success: + Logger.shared.log("Successfully updated Trakt progress for episode \(self.episodeNumber)", type: "General") + case .failure(let error): + Logger.shared.log("Failed to update Trakt progress: \(error.localizedDescription)", type: "Error") + } + } + } + } } self.sliderHostingController?.rootView = MusicProgressSlider( @@ -1796,6 +1826,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele @objc func seekBackward() { let skipValue = UserDefaults.standard.double(forKey: "skipIncrement") let finalSkip = skipValue > 0 ? skipValue : 10 + currentTimeVal = max(currentTimeVal - finalSkip, 0) player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in guard self != nil else { return } @@ -1865,8 +1896,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele guard isPipAutoEnabled, let pip = pipController, !pip.isPictureInPictureActive else { - return - } + return + } pip.startPictureInPicture() } @@ -2114,13 +2145,13 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private func processM3U8Data(data: Data?, url: URL, completion: @escaping () -> Void) { guard let data = data, let content = String(data: data, encoding: .utf8) else { - Logger.shared.log("Failed to load m3u8 file") - DispatchQueue.main.async { - self.qualities = [] - completion() - } - return - } + Logger.shared.log("Failed to load m3u8 file") + DispatchQueue.main.async { + self.qualities = [] + completion() + } + return + } let lines = content.components(separatedBy: .newlines) var qualities: [(String, String)] = [] @@ -2686,7 +2717,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele height: 10, onEditingChanged: { _ in } ) - .shadow(color: Color.black.opacity(0.6), radius: 4, x: 0, y: 2) + .shadow(color: Color.black.opacity(0.6), radius: 4, x: 0, y: 2) } } @@ -2701,11 +2732,11 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele default: return .white } } - + override var canBecomeFirstResponder: Bool { return true } - + override var keyCommands: [UIKeyCommand]? { return [ UIKeyCommand(input: " ", modifierFlags: [], action: #selector(handleSpaceKey), discoverabilityTitle: "Play/Pause"), @@ -2716,39 +2747,39 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele UIKeyCommand(input: UIKeyCommand.inputEscape, modifierFlags: [], action: #selector(handleEscape), discoverabilityTitle: "Dismiss Player") ] } - + @objc private func handleSpaceKey() { togglePlayPause() } - + @objc private func handleLeftArrow() { let skipValue = 10.0 currentTimeVal = max(currentTimeVal - skipValue, 0) player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) animateButtonRotation(backwardButton, clockwise: false) } - + @objc private func handleRightArrow() { let skipValue = 10.0 currentTimeVal = min(currentTimeVal + skipValue, duration) player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) animateButtonRotation(forwardButton) } - + @objc private func handleUpArrow() { let skipValue = 60.0 currentTimeVal = min(currentTimeVal + skipValue, duration) player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) animateButtonRotation(forwardButton) } - + @objc private func handleDownArrow() { let skipValue = 60.0 currentTimeVal = max(currentTimeVal - skipValue, 0) player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) animateButtonRotation(backwardButton, clockwise: false) } - + @objc private func handleEscape() { dismiss(animated: true, completion: nil) } diff --git a/Sora/Utils/MediaPlayer/VideoPlayer.swift b/Sora/Utils/MediaPlayer/VideoPlayer.swift index cc61a8e..c0a2bd5 100644 --- a/Sora/Utils/MediaPlayer/VideoPlayer.swift +++ b/Sora/Utils/MediaPlayer/VideoPlayer.swift @@ -20,7 +20,9 @@ class VideoPlayerViewController: UIViewController { var aniListID: Int = 0 var headers: [String:String]? = nil var totalEpisodes: Int = 0 - + var tmdbID: Int? = nil + var isMovie: Bool = false + var seasonNumber: Int = 1 var episodeNumber: Int = 0 var episodeImageUrl: String = "" var mediaTitle: String = "" @@ -200,14 +202,45 @@ class VideoPlayerViewController: UIViewController { let remainingPercentage = (duration - currentTime) / duration - if remainingPercentage < 0.1 && self.aniListID != 0 { - let aniListMutation = AniListMutation() - aniListMutation.updateAnimeProgress(animeId: self.aniListID, episodeNumber: self.episodeNumber) { result in - switch result { - case .success: - Logger.shared.log("Successfully updated AniList progress for episode \(self.episodeNumber)", type: "General") - case .failure(let error): - Logger.shared.log("Failed to update AniList progress: \(error.localizedDescription)", type: "Error") + if remainingPercentage < 0.1 { + if self.aniListID != 0 { + let aniListMutation = AniListMutation() + aniListMutation.updateAnimeProgress(animeId: self.aniListID, episodeNumber: self.episodeNumber) { result in + switch result { + case .success: + Logger.shared.log("Successfully updated AniList progress for episode \(self.episodeNumber)", type: "General") + case .failure(let error): + Logger.shared.log("Failed to update AniList progress: \(error.localizedDescription)", type: "Error") + } + } + } + + if let tmdbId = self.tmdbID { + let traktMutation = TraktMutation() + + if self.isMovie { + traktMutation.markAsWatched(type: "movie", externalID: .tmdb(tmdbId)) { result in + switch result { + case .success: + Logger.shared.log("Successfully updated Trakt progress for movie", type: "General") + case .failure(let error): + Logger.shared.log("Failed to update Trakt progress: \(error.localizedDescription)", type: "Error") + } + } + } else { + traktMutation.markAsWatched( + type: "episode", + externalID: .tmdb(tmdbId), + episodeNumber: self.episodeNumber, + seasonNumber: self.seasonNumber + ) { result in + switch result { + case .success: + Logger.shared.log("Successfully updated Trakt progress for episode \(self.episodeNumber)", type: "General") + case .failure(let error): + Logger.shared.log("Failed to update Trakt progress: \(error.localizedDescription)", type: "Error") + } + } } } } diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index c8bc411..0476211 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -1302,6 +1302,7 @@ struct MediaInfoView: View { videoPlayerViewController.streamUrl = url videoPlayerViewController.fullUrl = fullURL videoPlayerViewController.episodeNumber = selectedEpisodeNumber + videoPlayerViewController.seasonNumber = selectedSeason + 1 videoPlayerViewController.episodeImageUrl = selectedEpisodeImage videoPlayerViewController.mediaTitle = title videoPlayerViewController.subtitles = subtitles ?? "" @@ -1350,6 +1351,7 @@ struct MediaInfoView: View { episodeImageUrl: selectedEpisodeImage, headers: headers ?? nil ) + customMediaPlayer.seasonNumber = selectedSeason + 1 customMediaPlayer.modalPresentationStyle = .fullScreen Logger.shared.log("Opening custom media player with url: \(url)") From efe05e9a04ddecd6ced7445482e7558d8569e178 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Wed, 11 Jun 2025 10:37:40 +0200 Subject: [PATCH 12/52] 1.0.0 is better ngl --- Sulfur.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 7c33e67..be242eb 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -935,7 +935,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.3.0; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -977,7 +977,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.3.0; + MARKETING_VERSION = 1.0.0; PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; From 75c9d6bf07a799499459a036af4bdec720fde6b8 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Wed, 11 Jun 2025 11:14:21 +0200 Subject: [PATCH 13/52] plenty of things (fr) --- Sora/SoraApp.swift | 38 +------------ .../AniList/Auth/Anilist-Login.swift | 23 +++++--- .../Trakt/Auth/Trakt-Login.swift | 23 +++++--- .../Trakt/Mutations/TraktPushUpdates.swift | 56 ++++++++++--------- .../CustomPlayer/CustomPlayer.swift | 4 +- Sora/Utils/MediaPlayer/VideoPlayer.swift | 4 +- .../WebAuthenticationManager.swift | 45 +++++++++++++++ Sulfur.xcodeproj/project.pbxproj | 13 +++++ 8 files changed, 127 insertions(+), 79 deletions(-) create mode 100644 Sora/Utils/WebAuthentication/WebAuthenticationManager.swift diff --git a/Sora/SoraApp.swift b/Sora/SoraApp.swift index 79f8db0..d60ca21 100644 --- a/Sora/SoraApp.swift +++ b/Sora/SoraApp.swift @@ -89,11 +89,7 @@ struct SoraApp: App { } } .onOpenURL { url in - if let params = url.queryParameters, params["code"] != nil { - Self.handleRedirect(url: url) - } else { - handleURL(url) - } + handleURL(url) } } } @@ -142,38 +138,8 @@ struct SoraApp: App { 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 - } - - switch url.host { - case "anilist": - AniListToken.exchangeAuthorizationCodeForToken(code: code) { success in - if success { - Logger.shared.log("AniList token exchange successful") - } else { - Logger.shared.log("AniList token exchange failed", type: "Error") - } - } - - case "trakt": - TraktToken.exchangeAuthorizationCodeForToken(code: code) { success in - if success { - Logger.shared.log("Trakt token exchange successful") - } else { - Logger.shared.log("Trakt token exchange failed", type: "Error") - } - } - - default: - Logger.shared.log("Unknown authentication service", type: "Error") - } - } } + class AppInfo: NSObject { @objc func getBundleIdentifier() -> String { return Bundle.main.bundleIdentifier ?? "me.cranci.sulfur" diff --git a/Sora/Tracking Services/AniList/Auth/Anilist-Login.swift b/Sora/Tracking Services/AniList/Auth/Anilist-Login.swift index de958b1..aa4aadf 100644 --- a/Sora/Tracking Services/AniList/Auth/Anilist-Login.swift +++ b/Sora/Tracking Services/AniList/Auth/Anilist-Login.swift @@ -16,19 +16,28 @@ class AniListLogin { static func authenticate() { let urlString = "\(authorizationEndpoint)?client_id=\(clientID)&redirect_uri=\(redirectURI)&response_type=code" guard let url = URL(string: urlString) else { + Logger.shared.log("Invalid authorization URL", type: "Error") return } - if UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url, options: [:]) { success in - if success { - Logger.shared.log("Safari opened successfully", type: "Debug") + WebAuthenticationManager.shared.authenticate(url: url, callbackScheme: "sora") { result in + switch result { + case .success(let callbackURL): + if let params = callbackURL.queryParameters, + let code = params["code"] { + AniListToken.exchangeAuthorizationCodeForToken(code: code) { success in + if success { + Logger.shared.log("AniList token exchange successful", type: "Debug") + } else { + Logger.shared.log("AniList token exchange failed", type: "Error") + } + } } else { - Logger.shared.log("Failed to open Safari", type: "Error") + Logger.shared.log("No authorization code in callback URL", type: "Error") } + case .failure(let error): + Logger.shared.log("Authentication failed: \(error.localizedDescription)", type: "Error") } - } else { - Logger.shared.log("Cannot open URL", type: "Error") } } } diff --git a/Sora/Tracking Services/Trakt/Auth/Trakt-Login.swift b/Sora/Tracking Services/Trakt/Auth/Trakt-Login.swift index fc0c9c3..3dd98d1 100644 --- a/Sora/Tracking Services/Trakt/Auth/Trakt-Login.swift +++ b/Sora/Tracking Services/Trakt/Auth/Trakt-Login.swift @@ -16,19 +16,28 @@ class TraktLogin { static func authenticate() { let urlString = "\(authorizationEndpoint)?client_id=\(clientID)&redirect_uri=\(redirectURI)&response_type=code" guard let url = URL(string: urlString) else { + Logger.shared.log("Invalid authorization URL", type: "Error") return } - if UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url, options: [:]) { success in - if success { - Logger.shared.log("Safari opened successfully", type: "Debug") + WebAuthenticationManager.shared.authenticate(url: url, callbackScheme: "sora") { result in + switch result { + case .success(let callbackURL): + if let params = callbackURL.queryParameters, + let code = params["code"] { + TraktToken.exchangeAuthorizationCodeForToken(code: code) { success in + if success { + Logger.shared.log("Trakt token exchange successful", type: "Debug") + } else { + Logger.shared.log("Trakt token exchange failed", type: "Error") + } + } } else { - Logger.shared.log("Failed to open Safari", type: "Error") + Logger.shared.log("No authorization code in callback URL", type: "Error") } + case .failure(let error): + Logger.shared.log("Authentication failed: \(error.localizedDescription)", type: "Error") } - } else { - Logger.shared.log("Cannot open URL", type: "Error") } } } diff --git a/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift b/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift index 1d5dd3b..53ba1a8 100644 --- a/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift +++ b/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift @@ -30,18 +30,7 @@ class TraktMutation { return token } - enum ExternalIDType { - case tmdb(Int) - - var dictionary: [String: Any] { - switch self { - case .tmdb(let id): - return ["tmdb": id] - } - } - } - - func markAsWatched(type: String, externalID: ExternalIDType, episodeNumber: Int? = nil, seasonNumber: Int? = nil, completion: @escaping (Result) -> Void) { + func markAsWatched(type: String, tmdbID: Int, episodeNumber: Int? = nil, seasonNumber: Int? = nil, completion: @escaping (Result) -> Void) { if let sendTraktUpdates = UserDefaults.standard.object(forKey: "sendTraktUpdates") as? Bool, sendTraktUpdates == false { return @@ -49,10 +38,12 @@ class TraktMutation { guard let userToken = getTokenFromKeychain() else { completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Access token not found"]))) + Logger.shared.log("Trakt Access token not found", type: "Error") return } let endpoint = "/sync/history" + let watchedAt = ISO8601DateFormatter().string(from: Date()) let body: [String: Any] switch type { @@ -60,7 +51,8 @@ class TraktMutation { body = [ "movies": [ [ - "ids": externalID.dictionary + "ids": ["tmdb": tmdbID], + "watched_at": watchedAt ] ] ] @@ -74,12 +66,15 @@ class TraktMutation { body = [ "shows": [ [ - "ids": externalID.dictionary, + "ids": ["tmdb": tmdbID], "seasons": [ [ "number": season, "episodes": [ - ["number": episode] + [ + "number": episode, + "watched_at": watchedAt + ] ] ] ] @@ -94,13 +89,13 @@ class TraktMutation { var request = URLRequest(url: apiURL.appendingPathComponent(endpoint)) request.httpMethod = "POST" - request.setValue("Bearer \(userToken)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(userToken)", forHTTPHeaderField: "Authorization") request.setValue("2", forHTTPHeaderField: "trakt-api-version") request.setValue(TraktToken.clientID, forHTTPHeaderField: "trakt-api-key") do { - request.httpBody = try JSONSerialization.data(withJSONObject: body) + request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [.prettyPrinted]) } catch { completion(.failure(error)) return @@ -112,15 +107,26 @@ class TraktMutation { return } - guard let httpResponse = response as? HTTPURLResponse, - (200...299).contains(httpResponse.statusCode) else { - let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 - completion(.failure(NSError(domain: "", code: statusCode, userInfo: [NSLocalizedDescriptionKey: "Unexpected response or status code"]))) - return - } + guard let httpResponse = response as? HTTPURLResponse else { + completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "No HTTP response"]))) + return + } - Logger.shared.log("Successfully updated watch status on Trakt", type: "Debug") - completion(.success(())) + if (200...299).contains(httpResponse.statusCode) { + if let data = data, let responseString = String(data: data, encoding: .utf8) { + Logger.shared.log("Trakt API Response: \(responseString)", type: "Debug") + } + Logger.shared.log("Successfully updated watch status on Trakt", type: "Debug") + completion(.success(())) + } else { + var errorMessage = "Unexpected status code: \(httpResponse.statusCode)" + if let data = data, + let errorJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = errorJson["error"] as? String { + errorMessage = error + } + completion(.failure(NSError(domain: "", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: errorMessage]))) + } } task.resume() diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index c0d7e0e..4cc6d23 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -1656,7 +1656,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele let traktMutation = TraktMutation() if self.isMovie { - traktMutation.markAsWatched(type: "movie", externalID: .tmdb(tmdbId)) { result in + traktMutation.markAsWatched(type: "movie", tmdbID: tmdbId) { result in switch result { case .success: Logger.shared.log("Successfully updated Trakt progress for movie", type: "General") @@ -1667,7 +1667,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } else { traktMutation.markAsWatched( type: "episode", - externalID: .tmdb(tmdbId), + tmdbID: tmdbId, episodeNumber: self.episodeNumber, seasonNumber: self.seasonNumber ) { result in diff --git a/Sora/Utils/MediaPlayer/VideoPlayer.swift b/Sora/Utils/MediaPlayer/VideoPlayer.swift index c0a2bd5..4651392 100644 --- a/Sora/Utils/MediaPlayer/VideoPlayer.swift +++ b/Sora/Utils/MediaPlayer/VideoPlayer.swift @@ -219,7 +219,7 @@ class VideoPlayerViewController: UIViewController { let traktMutation = TraktMutation() if self.isMovie { - traktMutation.markAsWatched(type: "movie", externalID: .tmdb(tmdbId)) { result in + traktMutation.markAsWatched(type: "movie", tmdbID: tmdbId) { result in switch result { case .success: Logger.shared.log("Successfully updated Trakt progress for movie", type: "General") @@ -230,7 +230,7 @@ class VideoPlayerViewController: UIViewController { } else { traktMutation.markAsWatched( type: "episode", - externalID: .tmdb(tmdbId), + tmdbID: tmdbId, episodeNumber: self.episodeNumber, seasonNumber: self.seasonNumber ) { result in diff --git a/Sora/Utils/WebAuthentication/WebAuthenticationManager.swift b/Sora/Utils/WebAuthentication/WebAuthenticationManager.swift new file mode 100644 index 0000000..ba1b7f9 --- /dev/null +++ b/Sora/Utils/WebAuthentication/WebAuthenticationManager.swift @@ -0,0 +1,45 @@ +// +// WebAuthenticationManager.swift +// Sulfur +// +// Created by Francesco on 11/06/25. +// + +import AuthenticationServices + +class WebAuthenticationManager { + static let shared = WebAuthenticationManager() + private var webAuthSession: ASWebAuthenticationSession? + + func authenticate(url: URL, callbackScheme: String, completion: @escaping (Result) -> Void) { + webAuthSession = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackScheme) { callbackURL, error in + if let error = error { + completion(.failure(error)) + return + } + + if let callbackURL = callbackURL { + completion(.success(callbackURL)) + } else { + completion(.failure(NSError(domain: "WebAuthenticationManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "No callback URL received"]))) + } + } + + webAuthSession?.presentationContextProvider = WebAuthenticationPresentationContext.shared + webAuthSession?.prefersEphemeralWebBrowserSession = true + webAuthSession?.start() + } +} + +class WebAuthenticationPresentationContext: NSObject, ASWebAuthenticationPresentationContextProviding { + static let shared = WebAuthenticationPresentationContext() + + func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first else { + fatalError("No window found") + } + + return window + } +} diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index be242eb..d212f16 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 04F08EDF2DE10C1D006B29D9 /* TabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EDE2DE10C1A006B29D9 /* TabBar.swift */; }; 04F08EE22DE10C40006B29D9 /* TabItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EE12DE10C27006B29D9 /* TabItem.swift */; }; 04F08EE42DE10D6F006B29D9 /* AllBookmarks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EE32DE10D6B006B29D9 /* AllBookmarks.swift */; }; + 130326B62DF979AC00AEF610 /* WebAuthenticationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130326B52DF979AC00AEF610 /* WebAuthenticationManager.swift */; }; 130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */; }; 13103E8B2D58E028000F0673 /* View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8A2D58E028000F0673 /* View.swift */; }; 13103E8E2D58E04A000F0673 /* SkeletonCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13103E8D2D58E04A000F0673 /* SkeletonCell.swift */; }; @@ -114,6 +115,7 @@ 04F08EDE2DE10C1A006B29D9 /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = ""; }; 04F08EE12DE10C27006B29D9 /* TabItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItem.swift; sourceTree = ""; }; 04F08EE32DE10D6B006B29D9 /* AllBookmarks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllBookmarks.swift; sourceTree = ""; }; + 130326B52DF979AC00AEF610 /* WebAuthenticationManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebAuthenticationManager.swift; sourceTree = ""; }; 130C6BF82D53A4C200DC1432 /* Sora.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Sora.entitlements; sourceTree = ""; }; 130C6BF92D53AB1F00DC1432 /* SettingsViewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewData.swift; sourceTree = ""; }; 13103E8A2D58E028000F0673 /* View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = View.swift; sourceTree = ""; }; @@ -260,6 +262,15 @@ path = Models; sourceTree = ""; }; + 130326B42DF979A300AEF610 /* WebAuthentication */ = { + isa = PBXGroup; + children = ( + 130326B52DF979AC00AEF610 /* WebAuthenticationManager.swift */, + ); + name = WebAuthentication; + path = Sora/Utils/WebAuthentication; + sourceTree = SOURCE_ROOT; + }; 13103E802D589D6C000F0673 /* Tracking Services */ = { isa = PBXGroup; children = ( @@ -378,6 +389,7 @@ 133D7C852D2BE2640075467E /* Utils */ = { isa = PBXGroup; children = ( + 130326B42DF979A300AEF610 /* WebAuthentication */, 0457C5962DE7712A000AFBD9 /* ViewModifiers */, 04F08EE02DE10C22006B29D9 /* Models */, 04F08EDD2DE10C05006B29D9 /* TabBar */, @@ -699,6 +711,7 @@ 1E26E9E72DA9577900B9DC02 /* VolumeSlider.swift in Sources */, 136BBE802DB1038000906B5E /* Notification+Name.swift in Sources */, 13DB46902D900A38008CBC03 /* URL.swift in Sources */, + 130326B62DF979AC00AEF610 /* WebAuthenticationManager.swift in Sources */, 04F08EE42DE10D6F006B29D9 /* AllBookmarks.swift in Sources */, 138FE1D02DECA00D00936D81 /* TMDB-FetchID.swift in Sources */, 130C6BFA2D53AB1F00DC1432 /* SettingsViewData.swift in Sources */, From eaa6a6d9e08825ae322399fc8a34186cb549477a Mon Sep 17 00:00:00 2001 From: 50/50 <80717571+50n50@users.noreply.github.com> Date: Wed, 11 Jun 2025 16:37:28 +0200 Subject: [PATCH 14/52] Fixes (#170) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fixed episodecells getting stuck sliding * Enabled device scaling for ipad not good enough yet, not applied everywhere cuz idk where to apply exactly 💯 * Fixed blur in continue watching cells * Keyboard controls player * fixed downloadview buttons * Reduced tab bar outline opacity * Increased module selector hitbox * Fixed module add view * Fixed mediainfoview issues (description) + changed settingsviewdata footer medainfoview: 1: no swipe to go back 2: image shadows were fucked * Fixes --- Sora/Localizable.xcstrings | 9 +- .../Modules/ModuleAdditionSettingsView.swift | 74 ++++--- .../EpisodeCell/EpisodeCell.swift | 207 +++++++++++------- Sora/Views/MediaInfoView/MediaInfoView.swift | 36 +-- .../SearchView/SearchViewComponents.swift | 4 + .../SettingsSubViews/SettingsViewData.swift | 2 +- .../SettingsViewTrackers.swift | 2 +- .../xcshareddata/swiftpm/Package.resolved | 3 +- 8 files changed, 211 insertions(+), 126 deletions(-) diff --git a/Sora/Localizable.xcstrings b/Sora/Localizable.xcstrings index 1b90ac5..bc0c7de 100644 --- a/Sora/Localizable.xcstrings +++ b/Sora/Localizable.xcstrings @@ -318,6 +318,9 @@ }, "Open in AniList" : { + }, + "Original Poster" : { + }, "Play" : { @@ -339,9 +342,6 @@ }, "Remove" : { - }, - "Remove All Caches" : { - }, "Remove Documents" : { @@ -366,9 +366,6 @@ }, "Reset Progress" : { - }, - "Revert Module Poster" : { - }, "Running Sora %@ - cranci1" : { diff --git a/Sora/Utils/Modules/ModuleAdditionSettingsView.swift b/Sora/Utils/Modules/ModuleAdditionSettingsView.swift index 11929c4..c05c734 100644 --- a/Sora/Utils/Modules/ModuleAdditionSettingsView.swift +++ b/Sora/Utils/Modules/ModuleAdditionSettingsView.swift @@ -22,20 +22,20 @@ struct ModuleAdditionSettingsView: View { ZStack { LinearGradient( gradient: Gradient(colors: [ - colorScheme == .light ? Color.black : Color.white, - Color.accentColor.opacity(0.08) + colorScheme == .dark ? Color.black : Color.white, + Color.accentColor.opacity(0.05) ]), startPoint: .top, endPoint: .bottom ) - .ignoresSafeArea() + .ignoresSafeArea() VStack(spacing: 0) { HStack { Spacer() Capsule() .frame(width: 40, height: 5) - .foregroundColor(Color(.systemGray4)) + .foregroundColor(Color(.systemGray3)) .padding(.top, 10) Spacer() } @@ -57,17 +57,22 @@ struct ModuleAdditionSettingsView: View { } .frame(width: 90, height: 90) .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous)) - .shadow(color: Color.accentColor.opacity(0.18), radius: 10, x: 0, y: 6) + .shadow( + color: colorScheme == .dark + ? Color.black.opacity(0.3) + : Color.accentColor.opacity(0.15), + radius: 10, x: 0, y: 6 + ) .overlay( RoundedRectangle(cornerRadius: 22) - .stroke(Color.accentColor, lineWidth: 2) + .stroke(Color.accentColor.opacity(0.8), lineWidth: 2) ) .padding(.top, 10) VStack(spacing: 6) { Text(metadata.sourceName) .font(.system(size: 28, weight: .bold, design: .rounded)) - .foregroundColor(.primary) + .foregroundColor(colorScheme == .dark ? .white : .black) .multilineTextAlignment(.center) .padding(.top, 6) @@ -84,14 +89,19 @@ struct ModuleAdditionSettingsView: View { } .frame(width: 32, height: 32) .clipShape(Circle()) - .shadow(radius: 2) + .shadow( + color: colorScheme == .dark + ? Color.black.opacity(0.4) + : Color.gray.opacity(0.3), + radius: 2 + ) VStack(alignment: .leading, spacing: 0) { Text(metadata.author.name) .font(.headline) - .foregroundColor(.primary) + .foregroundColor(colorScheme == .dark ? .white : .black) Text("Author") .font(.caption2) - .foregroundColor(.secondary) + .foregroundColor(colorScheme == .dark ? Color.white.opacity(0.7) : Color.black.opacity(0.6)) } Spacer() } @@ -99,7 +109,11 @@ struct ModuleAdditionSettingsView: View { .padding(.vertical, 8) .background( Capsule() - .fill(Color.accentColor.opacity(colorScheme == .dark ? 0.13 : 0.08)) + .fill( + colorScheme == .dark + ? Color.accentColor.opacity(0.15) + : Color.accentColor.opacity(0.08) + ) ) .padding(.top, 2) } @@ -125,7 +139,7 @@ struct ModuleAdditionSettingsView: View { } .background( RoundedRectangle(cornerRadius: 22) - .fill(Color(.systemGray6).opacity(colorScheme == .dark ? 0.18 : 0.8)) + .fill(colorScheme == .dark ? Color.white.opacity(0.1) : Color.black.opacity(0.05)) ) .padding(.top, 18) .padding(.horizontal, 2) @@ -142,7 +156,7 @@ struct ModuleAdditionSettingsView: View { .padding(16) .background( RoundedRectangle(cornerRadius: 18) - .fill(Color(.systemGray6).opacity(colorScheme == .dark ? 0.13 : 0.85)) + .fill(colorScheme == .dark ? Color.white.opacity(0.08) : Color.black.opacity(0.04)) ) .padding(.top, 18) } @@ -152,8 +166,10 @@ struct ModuleAdditionSettingsView: View { VStack(spacing: 20) { ProgressView() .scaleEffect(1.5) + .tint(.accentColor) Text("Loading module information...") - .foregroundColor(.secondary) + .foregroundColor(colorScheme == .dark ? Color.white.opacity(0.7) : Color.black.opacity(0.6)) + .font(.body) } .frame(maxHeight: .infinity) .padding(.top, 100) @@ -165,6 +181,7 @@ struct ModuleAdditionSettingsView: View { Text(errorMessage) .foregroundColor(.red) .multilineTextAlignment(.center) + .font(.body) } .frame(maxHeight: .infinity) .padding(.top, 100) @@ -180,21 +197,26 @@ struct ModuleAdditionSettingsView: View { Text("Add Module") } .font(.headline) - .foregroundColor(colorScheme == .light ? .black : .white) + .foregroundColor(Color.accentColor) .frame(maxWidth: .infinity) .padding(.vertical, 14) .background( LinearGradient( gradient: Gradient(colors: [ - Color.accentColor.opacity(0.95), - Color.accentColor.opacity(0.7) + colorScheme == .dark ? Color.white : Color.black, + colorScheme == .dark ? Color.white.opacity(0.9) : Color.black.opacity(0.9) ]), startPoint: .leading, endPoint: .trailing ) .clipShape(RoundedRectangle(cornerRadius: 18)) ) - .shadow(color: Color.accentColor.opacity(0.18), radius: 8, x: 0, y: 4) + .shadow( + color: colorScheme == .dark + ? Color.black.opacity(0.3) + : Color.accentColor.opacity(0.25), + radius: 8, x: 0, y: 4 + ) .padding(.horizontal, 20) } .disabled(isLoading || moduleMetadata == nil) @@ -203,7 +225,7 @@ struct ModuleAdditionSettingsView: View { Button(action: { presentationMode.wrappedValue.dismiss() }) { Text("Cancel") .font(.body) - .foregroundColor(.secondary) + .foregroundColor(colorScheme == .dark ? Color.white.opacity(0.7) : Color.black.opacity(0.6)) .padding(.vertical, 8) } } @@ -271,18 +293,19 @@ struct FancyInfoTile: View { let icon: String let label: String let value: String + @Environment(\.colorScheme) var colorScheme var body: some View { VStack(spacing: 4) { Image(systemName: icon) .font(.system(size: 18, weight: .semibold)) - .foregroundColor(.accentColor) + .foregroundColor(colorScheme == .dark ? .white : .black) Text(label) .font(.caption2) - .foregroundColor(.secondary) + .foregroundColor(colorScheme == .dark ? Color.white.opacity(0.6) : Color.black.opacity(0.5)) Text(value) .font(.system(size: 15, weight: .semibold, design: .rounded)) - .foregroundColor(.primary) + .foregroundColor(colorScheme == .dark ? .white : .black) .lineLimit(1) .minimumScaleFactor(0.7) } @@ -294,16 +317,17 @@ struct FancyInfoTile: View { struct FancyUrlRow: View { let title: String let value: String + @Environment(\.colorScheme) var colorScheme var body: some View { HStack(spacing: 8) { Text(title) .font(.subheadline) - .foregroundColor(.secondary) + .foregroundColor(colorScheme == .dark ? Color.white.opacity(0.7) : Color.black.opacity(0.6)) Spacer() Text(value) .font(.footnote.monospaced()) - .foregroundColor(.accentColor) + .foregroundColor(colorScheme == .dark ? .white : .black) .lineLimit(1) .truncationMode(.middle) .onLongPressGesture { @@ -311,7 +335,7 @@ struct FancyUrlRow: View { DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill")) } Image(systemName: "doc.on.clipboard") - .foregroundColor(.accentColor) + .foregroundColor(colorScheme == .dark ? .white : .black) .font(.system(size: 14)) .onTapGesture { UIPasteboard.general.string = value diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index 0707d64..e53e394 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -44,10 +44,12 @@ struct EpisodeCell: View { @State private var lastLoggedStatus: EpisodeDownloadStatus? @State private var downloadAnimationScale: CGFloat = 1.0 + @State private var isActionsVisible = false + @State private var panGesture = UIPanGestureRecognizer() + @State private var swipeOffset: CGFloat = 0 @State private var isShowingActions: Bool = false @State private var actionButtonWidth: CGFloat = 60 - @State private var dragState: DragState = .inactive @State private var retryAttempts: Int = 0 private let maxRetryAttempts: Int = 3 @@ -186,28 +188,73 @@ struct EpisodeCell: View { ) ) .clipShape(RoundedRectangle(cornerRadius: 15)) - .offset(x: swipeOffset + dragState.translation.width) + .offset(x: swipeOffset) .zIndex(1) - .scaleEffect(dragState.isActive ? 0.98 : 1.0) - .animation(.spring(response: 0.4, dampingFraction: 0.8), value: swipeOffset) - .animation(.spring(response: 0.3, dampingFraction: 0.6), value: dragState.isActive) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: swipeOffset) .contextMenu { contextMenuContent } - .simultaneousGesture( - DragGesture(coordinateSpace: .local) + .highPriorityGesture( + DragGesture(minimumDistance: 10) .onChanged { value in - handleDragChanged(value) + let horizontalTranslation = value.translation.width + let verticalTranslation = value.translation.height + + // Only handle if it's a clear horizontal swipe + if abs(horizontalTranslation) > abs(verticalTranslation) * 1.5 { + if horizontalTranslation < 0 { + let maxSwipe = calculateMaxSwipeDistance() + swipeOffset = max(horizontalTranslation, -maxSwipe) + } else if isShowingActions { + let maxSwipe = calculateMaxSwipeDistance() + swipeOffset = max(horizontalTranslation - maxSwipe, -maxSwipe) + } + } } .onEnded { value in - handleDragEnded(value) + let horizontalTranslation = value.translation.width + let verticalTranslation = value.translation.height + + // Only handle if it was a clear horizontal swipe + if abs(horizontalTranslation) > abs(verticalTranslation) * 1.5 { + let maxSwipe = calculateMaxSwipeDistance() + let threshold = maxSwipe * 0.2 + + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + if horizontalTranslation < -threshold && !isShowingActions { + swipeOffset = -maxSwipe + isShowingActions = true + } else if horizontalTranslation > threshold && isShowingActions { + swipeOffset = 0 + isShowingActions = false + } else { + swipeOffset = isShowingActions ? -maxSwipe : 0 + } + } + } } ) } .onTapGesture { - handleTap() + if isShowingActions { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + swipeOffset = 0 + isShowingActions = false + } + } else if isMultiSelectMode { + onSelectionChanged?(!isSelected) + } else { + let imageUrl = episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl + onTap(imageUrl) + } } .onAppear { + // Configure the pan gesture + panGesture.delegate = nil + panGesture.cancelsTouchesInView = false + panGesture.delaysTouchesBegan = false + panGesture.delaysTouchesEnded = false + updateProgress() updateDownloadStatus() if UserDefaults.standard.string(forKey: "metadataProviders") ?? "TMDB" == "TMDB" { @@ -968,72 +1015,10 @@ struct EpisodeCell: View { .padding(.horizontal, 8) } - private func handleDragChanged(_ value: DragGesture.Value) { - let translation = value.translation - let velocity = value.velocity - - let isHorizontalGesture = abs(translation.width) > abs(translation.height) - let hasSignificantHorizontalMovement = abs(translation.width) > 10 - - if isHorizontalGesture && hasSignificantHorizontalMovement { - dragState = .dragging(translation: .zero) - - let proposedOffset = swipeOffset + translation.width - let maxSwipe = calculateMaxSwipeDistance() - - if translation.width < 0 { - let newOffset = max(proposedOffset, -maxSwipe) - if proposedOffset < -maxSwipe { - let resistance = abs(proposedOffset + maxSwipe) * 0.15 - swipeOffset = -maxSwipe - resistance - } else { - swipeOffset = newOffset - } - } else if isShowingActions { - swipeOffset = max(proposedOffset, -maxSwipe) - } - } else if !hasSignificantHorizontalMovement { - dragState = .inactive - } - } - - private func handleDragEnded(_ value: DragGesture.Value) { - let translation = value.translation - let velocity = value.velocity - - dragState = .inactive - - let isHorizontalGesture = abs(translation.width) > abs(translation.height) - let hasSignificantHorizontalMovement = abs(translation.width) > 10 - - if isHorizontalGesture && hasSignificantHorizontalMovement { - let maxSwipe = calculateMaxSwipeDistance() - let threshold = maxSwipe * 0.3 - let velocityThreshold: CGFloat = 500 - - withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { - if translation.width < -threshold || velocity.width < -velocityThreshold { - swipeOffset = -maxSwipe - isShowingActions = true - } else if translation.width > threshold || velocity.width > velocityThreshold { - swipeOffset = 0 - isShowingActions = false - } else { - swipeOffset = isShowingActions ? -maxSwipe : 0 - } - } - } else { - withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { - swipeOffset = isShowingActions ? -calculateMaxSwipeDistance() : 0 - } - } - } - private func handleTap() { - if isShowingActions { + if isActionsVisible { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - swipeOffset = 0 - isShowingActions = false + isActionsVisible = false } } else if isMultiSelectMode { onSelectionChanged?(!isSelected) @@ -1044,18 +1029,16 @@ struct EpisodeCell: View { } private func closeActionsIfNeeded() { - if isShowingActions { + if isActionsVisible { withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - swipeOffset = 0 - isShowingActions = false + isActionsVisible = false } } } private func closeActionsAndPerform(action: @escaping () -> Void) { withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { - swipeOffset = 0 - isShowingActions = false + isActionsVisible = false } DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { @@ -1063,3 +1046,69 @@ struct EpisodeCell: View { } } } + +struct UIViewWrapper: UIViewRepresentable { + let panGesture: UIPanGestureRecognizer + let onSwipe: (SwipeDirection) -> Void + + enum SwipeDirection { + case left, right, none + } + + func makeUIView(context: Context) -> UIView { + let view = UIView() + view.isUserInteractionEnabled = true + view.backgroundColor = .clear + + // Remove any existing gesture recognizers + if let existingGestures = view.gestureRecognizers { + for gesture in existingGestures { + view.removeGestureRecognizer(gesture) + } + } + + // Add the pan gesture + panGesture.addTarget(context.coordinator, action: #selector(Coordinator.handlePan(_:))) + view.addGestureRecognizer(panGesture) + + return view + } + + func updateUIView(_ uiView: UIView, context: Context) { + // Ensure the view is user interaction enabled + uiView.isUserInteractionEnabled = true + } + + func makeCoordinator() -> Coordinator { + Coordinator(onSwipe: onSwipe) + } + + class Coordinator: NSObject { + let onSwipe: (SwipeDirection) -> Void + + init(onSwipe: @escaping (SwipeDirection) -> Void) { + self.onSwipe = onSwipe + } + + @objc func handlePan(_ gesture: UIPanGestureRecognizer) { + let translation = gesture.translation(in: gesture.view) + let velocity = gesture.velocity(in: gesture.view) + + if gesture.state == .ended { + if abs(velocity.x) > abs(velocity.y) && abs(velocity.x) > 500 { + if velocity.x < 0 { + onSwipe(.left) + } else { + onSwipe(.right) + } + } else if abs(translation.x) > abs(translation.y) && abs(translation.x) > 50 { + if translation.x < 0 { + onSwipe(.left) + } else { + onSwipe(.right) + } + } + } + } + } +} diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 0476211..5ebae74 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -124,6 +124,13 @@ struct MediaInfoView: View { .onAppear { buttonRefreshTrigger.toggle() tabBarController.hideTabBar() + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let navigationController = window.rootViewController?.children.first as? UINavigationController { + navigationController.interactivePopGestureRecognizer?.isEnabled = true + navigationController.interactivePopGestureRecognizer?.delegate = nil + } } .onChange(of: selectedRange) { newValue in UserDefaults.standard.set(newValue.lowerBound, forKey: selectedRangeKey) @@ -224,16 +231,8 @@ struct MediaInfoView: View { Rectangle() .fill(Color.clear) .frame(height: 400) - VStack(alignment: .leading, spacing: 16) { - headerSection - if !episodeLinks.isEmpty { - episodesSection - } else { - noEpisodesSection - } - } - .padding() - .background( + + ZStack(alignment: .top) { LinearGradient( gradient: Gradient(stops: [ .init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.0), location: 0.0), @@ -244,9 +243,20 @@ struct MediaInfoView: View { startPoint: .top, endPoint: .bottom ) - .clipShape(RoundedRectangle(cornerRadius: 0)) - .shadow(color: (colorScheme == .dark ? Color.black : Color.white).opacity(1), radius: 10, x: 0, y: 10) - ) + .frame(height: 300) + .clipShape(RoundedRectangle(cornerRadius: 0)) + .shadow(color: (colorScheme == .dark ? Color.black : Color.white).opacity(1), radius: 10, x: 0, y: 10) + + VStack(alignment: .leading, spacing: 16) { + headerSection + if !episodeLinks.isEmpty { + episodesSection + } else { + noEpisodesSection + } + } + .padding() + } } } } diff --git a/Sora/Views/SearchView/SearchViewComponents.swift b/Sora/Views/SearchView/SearchViewComponents.swift index 49c1201..95ebab5 100644 --- a/Sora/Views/SearchView/SearchViewComponents.swift +++ b/Sora/Views/SearchView/SearchViewComponents.swift @@ -117,6 +117,10 @@ struct ModuleSelectorMenu: View { ) } } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(.systemGray6).opacity(0)) + .cornerRadius(12) } } } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift index c77f327..1aed8fa 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift @@ -154,7 +154,7 @@ struct SettingsViewData: View { VStack(spacing: 24) { SettingsSection( title: "App Storage", - footer: "The app cache allow the app to sho immages faster.\n\nClearing the documents folder will remove all the modules.\n\nThe App Data should never be erased if you don't know what that will cause." + footer: "The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nDo not erase App Data unless you understand the consequences — it may cause the app to malfunction." ) { VStack(spacing: 0) { SettingsButtonRow( diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift index 8dff56f..28f2cbd 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift @@ -297,7 +297,7 @@ struct SettingsViewTrackers: View { SettingsSection( title: "Info", - footer: "Sora and cranci1 are not affiliated with AniList nor Trakt in any way.\n\nAlso note that progresses update may not be 100% accurate." + footer: "Sora and Cranci1 are not affiliated with AniList or Trakt in any way.\n\nAlso note that progress updates may not be 100% accurate." ) {} } .padding(.vertical, 20) diff --git a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 0b5a161..d8a331d 100644 --- a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "e12f82ce5205016ea66a114308acd41450cfe950ccb1aacfe0e26181d2036fa4", "pins" : [ { "identity" : "drops", @@ -28,5 +29,5 @@ } } ], - "version" : 2 + "version" : 3 } From 0bbb99fc1884b14dff241ac8e0653fabc719e630 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Wed, 11 Jun 2025 16:39:54 +0200 Subject: [PATCH 15/52] =?UTF-8?q?crazy=20stuff=20=F0=9F=92=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SettingsView/SettingsSubViews/SettingsViewTrackers.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift index 28f2cbd..b5fae88 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift @@ -297,7 +297,7 @@ struct SettingsViewTrackers: View { SettingsSection( title: "Info", - footer: "Sora and Cranci1 are not affiliated with AniList or Trakt in any way.\n\nAlso note that progress updates may not be 100% accurate." + footer: "Sora and cranci1 are not affiliated with AniList or Trakt in any way.\n\nAlso note that progress updates may not be 100% accurate." ) {} } .padding(.vertical, 20) From c281290350351ccf8f91f956395e0b15763e50e8 Mon Sep 17 00:00:00 2001 From: realdoomsboygaming <105606471+realdoomsboygaming@users.noreply.github.com> Date: Wed, 11 Jun 2025 07:51:51 -0700 Subject: [PATCH 16/52] Fix Single Episode Download Jank (#171) --- Sora/Views/MediaInfoView/MediaInfoView.swift | 276 ++++++++++++++++++- 1 file changed, 274 insertions(+), 2 deletions(-) diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 5ebae74..6489ada 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -84,6 +84,7 @@ struct MediaInfoView: View { @State private var showRangeInput: Bool = false @State private var isBulkDownloading: Bool = false @State private var bulkDownloadProgress: String = "" + @State private var isSingleEpisodeDownloading: Bool = false @State private var tmdbType: TMDBFetcher.MediaType? = nil private var isGroupedBySeasons: Bool { @@ -375,8 +376,7 @@ struct MediaInfoView: View { ) if downloadStatus == .notDownloaded { - selectedEpisodeNumber = ep.number - startBulkDownload() + downloadSingleEpisodeDirectly(episode: ep) DropManager.shared.showDrop(title: "Starting Download", subtitle: "", duration: 1.0, icon: UIImage(systemName: "arrow.down.circle")) } else { DropManager.shared.showDrop(title: "Already Downloaded", subtitle: "", duration: 1.0, icon: UIImage(systemName: "checkmark.circle")) @@ -1920,4 +1920,276 @@ struct MediaInfoView: View { } }.resume() } + + // MARK: - Single Episode Download (Non-Bulk) + + private func downloadSingleEpisodeDirectly(episode: EpisodeLink) { + if isSingleEpisodeDownloading { + return + } + + isSingleEpisodeDownloading = true + + DropManager.shared.downloadStarted(episodeNumber: episode.number) + + Task { + do { + let jsContent = try moduleManager.getModuleContent(module) + jsController.loadScript(jsContent) + tryNextSingleDownloadMethod(episode: episode, methodIndex: 0, softsub: module.metadata.softsub == true) + } catch { + DropManager.shared.error("Failed to start download: \(error.localizedDescription)") + isSingleEpisodeDownloading = false + } + } + } + + private func tryNextSingleDownloadMethod(episode: EpisodeLink, methodIndex: Int, softsub: Bool) { + if !isSingleEpisodeDownloading { + return + } + + print("[Single Download] Trying download method #\(methodIndex+1) for Episode \(episode.number)") + + switch methodIndex { + case 0: + if module.metadata.asyncJS == true { + jsController.fetchStreamUrlJS(episodeUrl: episode.href, softsub: softsub, module: module) { result in + self.handleSingleDownloadResult(result, episode: episode, methodIndex: methodIndex, softsub: softsub) + } + } else { + tryNextSingleDownloadMethod(episode: episode, methodIndex: methodIndex + 1, softsub: softsub) + } + + case 1: + if module.metadata.streamAsyncJS == true { + jsController.fetchStreamUrlJSSecond(episodeUrl: episode.href, softsub: softsub, module: module) { result in + self.handleSingleDownloadResult(result, episode: episode, methodIndex: methodIndex, softsub: softsub) + } + } else { + tryNextSingleDownloadMethod(episode: episode, methodIndex: methodIndex + 1, softsub: softsub) + } + + case 2: + jsController.fetchStreamUrl(episodeUrl: episode.href, softsub: softsub, module: module) { result in + self.handleSingleDownloadResult(result, episode: episode, methodIndex: methodIndex, softsub: softsub) + } + + default: + DropManager.shared.error("Failed to find a valid stream for download after trying all methods") + isSingleEpisodeDownloading = false + } + } + + private func handleSingleDownloadResult(_ result: (streams: [String]?, subtitles: [String]?, sources: [[String:Any]]?), episode: EpisodeLink, methodIndex: Int, softsub: Bool) { + if !isSingleEpisodeDownloading { + return + } + + if let sources = result.sources, !sources.isEmpty { + if sources.count > 1 { + showSingleDownloadStreamSelectionAlert(streams: sources, episode: episode, subtitleURL: result.subtitles?.first) + return + } else if let streamUrl = sources[0]["streamUrl"] as? String, let url = URL(string: streamUrl) { + + let subtitleURLString = sources[0]["subtitle"] as? String + let subtitleURL = subtitleURLString.flatMap { URL(string: $0) } + if let subtitleURL = subtitleURL { + Logger.shared.log("[Single Download] Found subtitle URL: \(subtitleURL.absoluteString)") + } + + startSingleEpisodeDownloadWithProcessedStream(episode: episode, url: url, streamUrl: streamUrl, subtitleURL: subtitleURL) + return + } + } + + if let streams = result.streams, !streams.isEmpty { + if streams[0] == "[object Promise]" { + tryNextSingleDownloadMethod(episode: episode, methodIndex: methodIndex + 1, softsub: softsub) + return + } + + if streams.count > 1 { + showSingleDownloadStreamSelectionAlert(streams: streams, episode: episode, subtitleURL: result.subtitles?.first) + return + } else if let url = URL(string: streams[0]) { + let subtitleURL = result.subtitles?.first.flatMap { URL(string: $0) } + if let subtitleURL = subtitleURL { + Logger.shared.log("[Single Download] Found subtitle URL: \(subtitleURL.absoluteString)") + } + + startSingleEpisodeDownloadWithProcessedStream(episode: episode, url: url, streamUrl: streams[0], subtitleURL: subtitleURL) + return + } + } + + tryNextSingleDownloadMethod(episode: episode, methodIndex: methodIndex + 1, softsub: softsub) + } + + private func showSingleDownloadStreamSelectionAlert(streams: [Any], episode: EpisodeLink, subtitleURL: String? = nil) { + DispatchQueue.main.async { + let alert = UIAlertController(title: "Select Download Server", message: "Choose a server to download Episode \(episode.number) from", preferredStyle: .actionSheet) + + var index = 0 + var streamIndex = 1 + + while index < streams.count { + var title: String = "" + var streamUrl: String = "" + + if let streams = streams as? [String] { + if index + 1 < streams.count { + if !streams[index].lowercased().contains("http") { + title = streams[index] + streamUrl = streams[index + 1] + index += 2 + } else { + title = "Server \(streamIndex)" + streamUrl = streams[index] + index += 1 + } + } else { + title = "Server \(streamIndex)" + streamUrl = streams[index] + index += 1 + } + } else if let streams = streams as? [[String: Any]] { + if let currTitle = streams[index]["title"] as? String { + title = currTitle + } else { + title = "Server \(streamIndex)" + } + streamUrl = (streams[index]["streamUrl"] as? String) ?? "" + index += 1 + } + + alert.addAction(UIAlertAction(title: title, style: .default) { _ in + guard let url = URL(string: streamUrl) else { + DropManager.shared.error("Invalid stream URL selected") + self.isSingleEpisodeDownloading = false + return + } + + var subtitleURL: URL? = nil + if let streams = streams as? [[String: Any]], + let subtitleURLString = streams[index-1]["subtitle"] as? String { + subtitleURL = URL(string: subtitleURLString) + } + + self.startSingleEpisodeDownloadWithProcessedStream(episode: episode, url: url, streamUrl: streamUrl, subtitleURL: subtitleURL) + }) + + streamIndex += 1 + } + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in + self.isSingleEpisodeDownloading = false + }) + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let rootVC = window.rootViewController { + + if UIDevice.current.userInterfaceIdiom == .pad { + if let popover = alert.popoverPresentationController { + popover.sourceView = window + popover.sourceRect = CGRect( + x: UIScreen.main.bounds.width / 2, + y: UIScreen.main.bounds.height / 2, + width: 0, + height: 0 + ) + popover.permittedArrowDirections = [] + } + } + + findTopViewController.findViewController(rootVC).present(alert, animated: true) + } + } + } + + private func startSingleEpisodeDownloadWithProcessedStream(episode: EpisodeLink, url: URL, streamUrl: String, subtitleURL: URL? = nil) { + var headers: [String: String] = [:] + + if !module.metadata.baseUrl.isEmpty && !module.metadata.baseUrl.contains("undefined") { + print("Using module baseUrl: \(module.metadata.baseUrl)") + + headers = [ + "Origin": module.metadata.baseUrl, + "Referer": module.metadata.baseUrl, + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.9", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin" + ] + } else { + if let scheme = url.scheme, let host = url.host { + let baseUrl = scheme + "://" + host + + headers = [ + "Origin": baseUrl, + "Referer": baseUrl, + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.9", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin" + ] + } else { + DropManager.shared.error("Invalid stream URL - missing scheme or host") + isSingleEpisodeDownloading = false + return + } + } + + print("Single download headers: \(headers)") + + fetchEpisodeMetadataForDownload(episode: episode) { metadata in + let episodeTitle = metadata?.title["en"] ?? metadata?.title.values.first ?? "" + let episodeImageUrl = metadata?.imageUrl ?? "" + + let episodeName = metadata?.title["en"] ?? "Episode \(episode.number)" + let fullEpisodeTitle = episodeName + + let episodeThumbnailURL: URL? + if !episodeImageUrl.isEmpty { + episodeThumbnailURL = URL(string: episodeImageUrl) + } else { + episodeThumbnailURL = URL(string: self.getBannerImageBasedOnAppearance()) + } + + let showPosterImageURL = URL(string: self.imageUrl) + + print("[Single Download] Using episode metadata - Title: '\(fullEpisodeTitle)', Image: '\(episodeImageUrl.isEmpty ? "default banner" : episodeImageUrl)'") + + self.jsController.downloadWithStreamTypeSupport( + url: url, + headers: headers, + title: fullEpisodeTitle, + imageURL: episodeThumbnailURL, + module: self.module, + isEpisode: true, + showTitle: self.title, + season: 1, + episode: episode.number, + subtitleURL: subtitleURL, + showPosterURL: showPosterImageURL, + completionHandler: { success, message in + if success { + Logger.shared.log("Started download for Episode \(episode.number): \(episode.href)", type: "Download") + AnalyticsManager.shared.sendEvent( + event: "download", + additionalData: ["episode": episode.number, "url": streamUrl] + ) + } else { + DropManager.shared.error(message) + } + self.isSingleEpisodeDownloading = false + } + ) + } + } } From 626e5df5955ceca0e9cbd81570d3225bb65130f5 Mon Sep 17 00:00:00 2001 From: 50/50 <80717571+50n50@users.noreply.github.com> Date: Wed, 11 Jun 2025 18:19:34 +0200 Subject: [PATCH 17/52] Splashscreen (#172) --- .../SplashScreenIcon.imageset/Contents.json | 21 ++++++++ .../SplashScreenIcon.png | Bin 0 -> 90083 bytes Sora/SoraApp.swift | 2 +- Sora/Views/SplashScreenView.swift | 48 ++++++++++++++++++ Sulfur.xcodeproj/project.pbxproj | 4 ++ 5 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 Sora/Assets.xcassets/SplashScreenIcon.imageset/Contents.json create mode 100644 Sora/Assets.xcassets/SplashScreenIcon.imageset/SplashScreenIcon.png create mode 100644 Sora/Views/SplashScreenView.swift diff --git a/Sora/Assets.xcassets/SplashScreenIcon.imageset/Contents.json b/Sora/Assets.xcassets/SplashScreenIcon.imageset/Contents.json new file mode 100644 index 0000000..7d4793c --- /dev/null +++ b/Sora/Assets.xcassets/SplashScreenIcon.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "SplashScreenIcon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sora/Assets.xcassets/SplashScreenIcon.imageset/SplashScreenIcon.png b/Sora/Assets.xcassets/SplashScreenIcon.imageset/SplashScreenIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..c8f6f6ff2d6b7b5a209f805894da0586790d79fc GIT binary patch literal 90083 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 literal 0 HcmV?d00001 diff --git a/Sora/SoraApp.swift b/Sora/SoraApp.swift index d60ca21..3587eb1 100644 --- a/Sora/SoraApp.swift +++ b/Sora/SoraApp.swift @@ -73,7 +73,7 @@ struct SoraApp: App { var body: some Scene { WindowGroup { - ContentView() + SplashScreenView() .environmentObject(moduleManager) .environmentObject(settings) .environmentObject(librarykManager) diff --git a/Sora/Views/SplashScreenView.swift b/Sora/Views/SplashScreenView.swift new file mode 100644 index 0000000..8ceb17c --- /dev/null +++ b/Sora/Views/SplashScreenView.swift @@ -0,0 +1,48 @@ +// +// SplashScreenView.swift +// Sora +// +// Created by paul on 11/06/25. +// + +import SwiftUI + +struct SplashScreenView: View { + @State private var isAnimating = false + @State private var showMainApp = false + + var body: some View { + ZStack { + if showMainApp { + ContentView() + } else { + VStack { + Image("SplashScreenIcon") + .resizable() + .scaledToFit() + .frame(width: 200, height: 200) + .cornerRadius(24) + .scaleEffect(isAnimating ? 1.2 : 1.0) + .opacity(isAnimating ? 1.0 : 0.0) + + Text("Sora") + .font(.largeTitle) + .fontWeight(.bold) + .opacity(isAnimating ? 1.0 : 0.0) + } + .onAppear { + withAnimation(.easeIn(duration: 0.5)) { + isAnimating = true + } + + // Transition to main app after animation + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation(.easeOut(duration: 0.5)) { + showMainApp = true + } + } + } + } + } + } +} \ No newline at end of file diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index d212f16..46b16e2 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -18,6 +18,7 @@ 0457C59F2DE78267000AFBD9 /* BookmarkGridItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C5992DE78267000AFBD9 /* BookmarkGridItemView.swift */; }; 0457C5A12DE78385000AFBD9 /* BookmarksDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C5A02DE78385000AFBD9 /* BookmarksDetailView.swift */; }; 04CD76DB2DE20F2200733536 /* AllWatching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04CD76DA2DE20F2200733536 /* AllWatching.swift */; }; + 04EAC39A2DF9E0DB00BBD483 /* SplashScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04EAC3992DF9E0DB00BBD483 /* SplashScreenView.swift */; }; 04F08EDC2DE10BF3006B29D9 /* ProgressiveBlurView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */; }; 04F08EDF2DE10C1D006B29D9 /* TabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EDE2DE10C1A006B29D9 /* TabBar.swift */; }; 04F08EE22DE10C40006B29D9 /* TabItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04F08EE12DE10C27006B29D9 /* TabItem.swift */; }; @@ -111,6 +112,7 @@ 0457C59B2DE78267000AFBD9 /* BookmarkLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkLink.swift; sourceTree = ""; }; 0457C5A02DE78385000AFBD9 /* BookmarksDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksDetailView.swift; sourceTree = ""; }; 04CD76DA2DE20F2200733536 /* AllWatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllWatching.swift; sourceTree = ""; }; + 04EAC3992DF9E0DB00BBD483 /* SplashScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplashScreenView.swift; sourceTree = ""; }; 04F08EDB2DE10BEC006B29D9 /* ProgressiveBlurView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressiveBlurView.swift; sourceTree = ""; }; 04F08EDE2DE10C1A006B29D9 /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = ""; }; 04F08EE12DE10C27006B29D9 /* TabItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabItem.swift; sourceTree = ""; }; @@ -351,6 +353,7 @@ 133D7C7B2D2BE2630075467E /* Views */ = { isa = PBXGroup; children = ( + 04EAC3992DF9E0DB00BBD483 /* SplashScreenView.swift */, 72443C7C2DC8036500A61321 /* DownloadView.swift */, 0402DA122DE7B5EC003BB42C /* SearchView */, 133D7C7F2D2BE2630075467E /* MediaInfoView */, @@ -730,6 +733,7 @@ 13EA2BD62D32D97400C1EBD7 /* Double+Extension.swift in Sources */, 133D7C8F2D2BE2640075467E /* MediaInfoView.swift in Sources */, 04F08EE22DE10C40006B29D9 /* TabItem.swift in Sources */, + 04EAC39A2DF9E0DB00BBD483 /* SplashScreenView.swift in Sources */, 132AF1212D99951700A0140B /* JSController-Streams.swift in Sources */, 131845F92D47C62D00CA7A54 /* SettingsViewGeneral.swift in Sources */, 04CD76DB2DE20F2200733536 /* AllWatching.swift in Sources */, From 0933bc448cad20dd8175848870a6d7e3a05aac5c Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Wed, 11 Jun 2025 20:35:02 +0200 Subject: [PATCH 18/52] =?UTF-8?q?im=20dumb=20=F0=9F=98=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sora/SoraApp.swift | 10 ++++++++++ Sora/Utils/Extensions/JavaScriptCore+Extensions.swift | 1 + 2 files changed, 11 insertions(+) diff --git a/Sora/SoraApp.swift b/Sora/SoraApp.swift index 3587eb1..8833a7d 100644 --- a/Sora/SoraApp.swift +++ b/Sora/SoraApp.swift @@ -148,6 +148,16 @@ class AppInfo: NSObject { @objc func getDisplayName() -> String { return Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as! String } + + @objc func isValidApp() -> Bool { + let bundleId = getBundleIdentifier().lowercased() + let displayName = getDisplayName().lowercased() + + let hasValidBundleId = bundleId.contains("sulfur") + let hasValidDisplayName = displayName == "sora" || displayName == "sulfur" + + return hasValidBundleId && hasValidDisplayName + } } class AppDelegate: NSObject, UIApplicationDelegate { diff --git a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift index 791af6f..6855fb9 100644 --- a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift +++ b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift @@ -30,6 +30,7 @@ extension JSContext { Logger.shared.log("JavaScript log: \(message)", type: "Debug") } self.setObject(logFunction, forKeyedSubscript: "log" as NSString) + self.setObject(appInfoBridge, forKeyedSubscript: "AppInfo" as NSString) } func setupNativeFetch() { From 3901010309dff059693fe3a2cb1ccc6ce54b710e Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Wed, 11 Jun 2025 20:43:11 +0200 Subject: [PATCH 19/52] ? --- Sora/Utils/Extensions/JavaScriptCore+Extensions.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift index 6855fb9..dd4e9f2 100644 --- a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift +++ b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift @@ -10,9 +10,7 @@ import JavaScriptCore extension JSContext { func setupConsoleLogging() { let consoleObject = JSValue(newObjectIn: self) - let appInfoBridge = AppInfo() - consoleObject?.setObject(appInfoBridge, forKeyedSubscript: "AppInfo" as NSString) let consoleLogFunction: @convention(block) (String) -> Void = { message in Logger.shared.log(message, type: "Debug") From 2fc8e1205e1801f4b113c9b01631d4e57b5ed359 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Wed, 11 Jun 2025 20:51:15 +0200 Subject: [PATCH 20/52] =?UTF-8?q?opssii=20=F0=9F=98=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sora/SoraApp.swift | 52 ++----------------- .../JavaScriptCore+Extensions.swift | 5 +- 2 files changed, 6 insertions(+), 51 deletions(-) diff --git a/Sora/SoraApp.swift b/Sora/SoraApp.swift index 8833a7d..802c3b7 100644 --- a/Sora/SoraApp.swift +++ b/Sora/SoraApp.swift @@ -6,51 +6,9 @@ // import SwiftUI -import UIKit - -class OrientationManager: ObservableObject { - static let shared = OrientationManager() - - @Published var isLocked = false - private var lockedOrientation: UIInterfaceOrientationMask = .all - - private init() {} - - func lockOrientation() { - let currentOrientation = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation ?? .portrait - - switch currentOrientation { - case .portrait, .portraitUpsideDown: - lockedOrientation = .portrait - case .landscapeLeft, .landscapeRight: - lockedOrientation = .landscape - default: - lockedOrientation = .portrait - } - - isLocked = true - - UIDevice.current.setValue(currentOrientation.rawValue, forKey: "orientation") - UIViewController.attemptRotationToDeviceOrientation() - } - - func unlockOrientation(after delay: TimeInterval = 0.0) { - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - self.isLocked = false - self.lockedOrientation = .all - - UIViewController.attemptRotationToDeviceOrientation() - } - } - - func supportedOrientations() -> UIInterfaceOrientationMask { - return isLocked ? lockedOrientation : .all - } -} @main struct SoraApp: App { - @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @StateObject private var settings = Settings() @StateObject private var moduleManager = ModuleManager() @StateObject private var librarykManager = LibraryManager() @@ -140,7 +98,9 @@ struct SoraApp: App { } } -class AppInfo: NSObject { +@objc class AppInfo: NSObject { + @objc static let shared = AppInfo() + @objc func getBundleIdentifier() -> String { return Bundle.main.bundleIdentifier ?? "me.cranci.sulfur" } @@ -159,9 +119,3 @@ class AppInfo: NSObject { return hasValidBundleId && hasValidDisplayName } } - -class AppDelegate: NSObject, UIApplicationDelegate { - func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask { - return OrientationManager.shared.supportedOrientations() - } -} diff --git a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift index dd4e9f2..0051c0e 100644 --- a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift +++ b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift @@ -10,7 +10,9 @@ import JavaScriptCore extension JSContext { func setupConsoleLogging() { let consoleObject = JSValue(newObjectIn: self) - let appInfoBridge = AppInfo() + let appInfoBridge = AppInfo.shared + + self.setObject(appInfoBridge, forKeyedSubscript: "AppInfo" as NSString) let consoleLogFunction: @convention(block) (String) -> Void = { message in Logger.shared.log(message, type: "Debug") @@ -28,7 +30,6 @@ extension JSContext { Logger.shared.log("JavaScript log: \(message)", type: "Debug") } self.setObject(logFunction, forKeyedSubscript: "log" as NSString) - self.setObject(appInfoBridge, forKeyedSubscript: "AppInfo" as NSString) } func setupNativeFetch() { From 3e54e2d7d88dbbe4f8072f93af7d771179fbb8e4 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Wed, 11 Jun 2025 20:52:38 +0200 Subject: [PATCH 21/52] less animation time --- Sora/Views/SplashScreenView.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Sora/Views/SplashScreenView.swift b/Sora/Views/SplashScreenView.swift index 8ceb17c..5851fa9 100644 --- a/Sora/Views/SplashScreenView.swift +++ b/Sora/Views/SplashScreenView.swift @@ -31,13 +31,12 @@ struct SplashScreenView: View { .opacity(isAnimating ? 1.0 : 0.0) } .onAppear { - withAnimation(.easeIn(duration: 0.5)) { + withAnimation(.easeIn(duration: 0.25)) { isAnimating = true } - // Transition to main app after animation - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - withAnimation(.easeOut(duration: 0.5)) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { + withAnimation(.easeOut(duration: 0.25)) { showMainApp = true } } @@ -45,4 +44,4 @@ struct SplashScreenView: View { } } } -} \ No newline at end of file +} From 0bd5b37edc0177d9d857b8b95b8102b4b1e6f9ea Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Wed, 11 Jun 2025 20:57:52 +0200 Subject: [PATCH 22/52] fixed paul ok --- Sora/Views/SplashScreenView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sora/Views/SplashScreenView.swift b/Sora/Views/SplashScreenView.swift index 5851fa9..c906161 100644 --- a/Sora/Views/SplashScreenView.swift +++ b/Sora/Views/SplashScreenView.swift @@ -31,12 +31,12 @@ struct SplashScreenView: View { .opacity(isAnimating ? 1.0 : 0.0) } .onAppear { - withAnimation(.easeIn(duration: 0.25)) { + withAnimation(.easeIn(duration: 0.5)) { isAnimating = true } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { - withAnimation(.easeOut(duration: 0.25)) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation(.easeOut(duration: 0.5)) { showMainApp = true } } From 23ecb6f53a1209c89bbdb53fc81f3445bbfae081 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Wed, 11 Jun 2025 21:06:49 +0200 Subject: [PATCH 23/52] splash screen fixes cuz people too pichy --- Sora/SoraApp.swift | 38 +++++++++++-------- .../SettingsViewGeneral.swift | 7 ++++ Sora/Views/SplashScreenView.swift | 7 +--- 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/Sora/SoraApp.swift b/Sora/SoraApp.swift index 802c3b7..86ddd7d 100644 --- a/Sora/SoraApp.swift +++ b/Sora/SoraApp.swift @@ -31,24 +31,30 @@ struct SoraApp: App { var body: some Scene { WindowGroup { - SplashScreenView() - .environmentObject(moduleManager) - .environmentObject(settings) - .environmentObject(librarykManager) - .environmentObject(downloadManager) - .environmentObject(jsController) - .accentColor(settings.accentColor) - .onAppear { - settings.updateAppearance() - Task { - if UserDefaults.standard.bool(forKey: "refreshModulesOnLaunch") { - await moduleManager.refreshModules() - } + Group { + if !UserDefaults.standard.bool(forKey: "hideSplashScreenEnable") { + SplashScreenView() + } else { + ContentView() + } + } + .environmentObject(moduleManager) + .environmentObject(settings) + .environmentObject(librarykManager) + .environmentObject(downloadManager) + .environmentObject(jsController) + .accentColor(settings.accentColor) + .onAppear { + settings.updateAppearance() + Task { + if UserDefaults.standard.bool(forKey: "refreshModulesOnLaunch") { + await moduleManager.refreshModules() } } - .onOpenURL { url in - handleURL(url) - } + } + .onOpenURL { url in + handleURL(url) + } } } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift index 331f3da..8510192 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift @@ -153,6 +153,7 @@ struct SettingsViewGeneral: View { @AppStorage("refreshModulesOnLaunch") private var refreshModulesOnLaunch: Bool = true @AppStorage("fetchEpisodeMetadata") private var fetchEpisodeMetadata: Bool = true @AppStorage("analyticsEnabled") private var analyticsEnabled: Bool = false + @AppStorage("hideSplashScreenEnable") private var hideSplashScreenEnable: Bool = false @AppStorage("metadataProviders") private var metadataProviders: String = "TMDB" @AppStorage("tmdbImageWidth") private var TMDBimageWidht: String = "original" @AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2 @@ -180,6 +181,12 @@ struct SettingsViewGeneral: View { }, selection: $settings.selectedAppearance ) + + SettingsToggleRow( + icon: "wand.and.rays.inverse", + title: "Hide Splash Screen", + isOn: $hideSplashScreenEnable + ) } SettingsSection( diff --git a/Sora/Views/SplashScreenView.swift b/Sora/Views/SplashScreenView.swift index c906161..4833ac6 100644 --- a/Sora/Views/SplashScreenView.swift +++ b/Sora/Views/SplashScreenView.swift @@ -24,18 +24,13 @@ struct SplashScreenView: View { .cornerRadius(24) .scaleEffect(isAnimating ? 1.2 : 1.0) .opacity(isAnimating ? 1.0 : 0.0) - - Text("Sora") - .font(.largeTitle) - .fontWeight(.bold) - .opacity(isAnimating ? 1.0 : 0.0) } .onAppear { withAnimation(.easeIn(duration: 0.5)) { isAnimating = true } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { withAnimation(.easeOut(duration: 0.5)) { showMainApp = true } From 05c9722142b19730babc9ef42fa7576b1a35b7b8 Mon Sep 17 00:00:00 2001 From: realdoomsboygaming <105606471+realdoomsboygaming@users.noreply.github.com> Date: Wed, 11 Jun 2025 12:09:09 -0700 Subject: [PATCH 24/52] Yes (#173) * MediaInfoViewRefactor * EpisodeCellRefactor * Fix Crash YATTA * removed comments * fix * Cranc1 is a picky boi --------- Co-authored-by: cranci <100066266+cranci1@users.noreply.github.com> --- Sora/Utils/SkeletonCells/Shimmer.swift | 87 +- .../EpisodeCell/EpisodeCell.swift | 1457 ++++----- Sora/Views/MediaInfoView/MediaInfoView.swift | 2609 +++++++---------- 3 files changed, 1838 insertions(+), 2315 deletions(-) diff --git a/Sora/Utils/SkeletonCells/Shimmer.swift b/Sora/Utils/SkeletonCells/Shimmer.swift index 3575963..75625ba 100644 --- a/Sora/Utils/SkeletonCells/Shimmer.swift +++ b/Sora/Utils/SkeletonCells/Shimmer.swift @@ -9,55 +9,84 @@ import SwiftUI struct Shimmer: ViewModifier { @State private var phase: CGFloat = -1 + @State private var isVisible: Bool = true func body(content: Content) -> some View { content - .modifier(AnimatedMask(phase: phase) - .animation( - Animation.linear(duration: 1.2) - .repeatForever(autoreverses: false) - ) - ) + .modifier(AnimatedMask(phase: phase, isVisible: isVisible)) .onAppear { - phase = 1.5 + isVisible = true + withAnimation( + Animation.linear(duration: 1.2) + .repeatForever(autoreverses: false) + ) { + phase = 1.5 + } + } + .onDisappear { + isVisible = false + phase = -1 } } struct AnimatedMask: AnimatableModifier { var phase: CGFloat = 0 + let isVisible: Bool var animatableData: CGFloat { get { phase } - set { phase = newValue } + set { + if isVisible { + phase = newValue + } + } } func body(content: Content) -> some View { content .overlay( - GeometryReader { geo in - let width = geo.size.width - let shimmerStart = phase - 0.25 - let shimmerEnd = phase + 0.25 - Rectangle() - .fill( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color.white.opacity(0.05), location: shimmerStart - 0.15), - .init(color: Color.white.opacity(0.25), location: shimmerStart), - .init(color: Color.white.opacity(0.85), location: phase), - .init(color: Color.white.opacity(0.25), location: shimmerEnd), - .init(color: Color.white.opacity(0.05), location: shimmerEnd + 0.15) - ]), - startPoint: .leading, - endPoint: .trailing - ) - ) - .blur(radius: 8) - .rotationEffect(.degrees(20)) - .offset(x: -width * 0.7 + width * 2 * phase) + Group { + if isVisible && phase > -1 { + shimmerOverlay + } else { + EmptyView() + } } ) .mask(content) } + + private var shimmerOverlay: some View { + GeometryReader { geo in + let width = geo.size.width + + let shimmerStart = phase - 0.25 + let shimmerEnd = phase + 0.25 + + Rectangle() + .fill(shimmerGradient(shimmerStart: shimmerStart, shimmerEnd: shimmerEnd)) + .blur(radius: 8) + .rotationEffect(.degrees(20)) + .offset(x: -width * 0.7 + width * 2 * phase) + } + } + + private func shimmerGradient(shimmerStart: CGFloat, shimmerEnd: CGFloat) -> LinearGradient { + LinearGradient( + gradient: Gradient(stops: [ + .init(color: shimmerColor1, location: shimmerStart - 0.15), + .init(color: shimmerColor2, location: shimmerStart), + .init(color: shimmerColor3, location: phase), + .init(color: shimmerColor2, location: shimmerEnd), + .init(color: shimmerColor1, location: shimmerEnd + 0.15) + ]), + startPoint: .leading, + endPoint: .trailing + ) + } + + private let shimmerColor1 = Color.white.opacity(0.05) + private let shimmerColor2 = Color.white.opacity(0.25) + private let shimmerColor3 = Color.white.opacity(0.85) } } diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index e53e394..7415c02 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -9,113 +9,77 @@ import NukeUI import SwiftUI import AVFoundation + struct EpisodeCell: View { + + let episodeIndex: Int let episode: String let episodeID: Int let progress: Double let itemID: Int - var totalEpisodes: Int? - var defaultBannerImage: String - var module: ScrapingModule - var parentTitle: String - var showPosterURL: String? + let totalEpisodes: Int? + let defaultBannerImage: String + let module: ScrapingModule + let parentTitle: String + let showPosterURL: String? + let tmdbID: Int? + let seasonNumber: Int? - var isMultiSelectMode: Bool = false - var isSelected: Bool = false - var onSelectionChanged: ((Bool) -> Void)? + + let isMultiSelectMode: Bool + let isSelected: Bool + let onSelectionChanged: ((Bool) -> Void)? - var onTap: (String) -> Void - var onMarkAllPrevious: () -> Void + + let onTap: (String) -> Void + let onMarkAllPrevious: () -> Void - @State private var episodeTitle: String = "" - @State private var episodeImageUrl: String = "" - @State private var isLoading: Bool = true + + @State private var episodeTitle = "" + @State private var episodeImageUrl = "" + @State private var isLoading = true @State private var currentProgress: Double = 0.0 - @State private var showDownloadConfirmation = false - @State private var isDownloading: Bool = false - @State private var isPlaying = false - @State private var loadedFromCache: Bool = false + @State private var isDownloading = false @State private var downloadStatus: EpisodeDownloadStatus = .notDownloaded - @State private var downloadRefreshTrigger: Bool = false - @State private var lastUpdateTime: Date = Date() - @State private var activeDownloadTask: AVAssetDownloadTask? = nil - @State private var lastStatusCheck: Date = Date() - @State private var lastLoggedStatus: EpisodeDownloadStatus? @State private var downloadAnimationScale: CGFloat = 1.0 + @State private var activeDownloadTask: AVAssetDownloadTask? - @State private var isActionsVisible = false - @State private var panGesture = UIPanGestureRecognizer() - + @State private var swipeOffset: CGFloat = 0 - @State private var isShowingActions: Bool = false + @State private var isShowingActions = false @State private var actionButtonWidth: CGFloat = 60 - @State private var retryAttempts: Int = 0 - private let maxRetryAttempts: Int = 3 + + @State private var retryAttempts = 0 + private let maxRetryAttempts = 3 private let initialBackoffDelay: TimeInterval = 1.0 + @ObservedObject private var jsController = JSController.shared @EnvironmentObject var moduleManager: ModuleManager - @Environment(\.colorScheme) private var colorScheme @AppStorage("selectedAppearance") private var selectedAppearance: Appearance = .system - enum DragState { - case inactive - case pressing - case dragging(translation: CGSize) - - var translation: CGSize { - switch self { - case .inactive, .pressing: - return .zero - case .dragging(let translation): - return translation - } - } - - var isActive: Bool { - switch self { - case .inactive: - return false - case .pressing, .dragging: - return true - } - } - - var isDragging: Bool { - switch self { - case .dragging: - return true - default: - return false - } - } - } - - private var downloadStatusString: String { - switch downloadStatus { - case .notDownloaded: - return "notDownloaded" - case .downloading(let download): - return "downloading_\(download.id)" - case .downloaded(let asset): - return "downloaded_\(asset.id)" - } - } - - let tmdbID: Int? - let seasonNumber: Int? - init(episodeIndex: Int, episode: String, episodeID: Int, progress: Double, - itemID: Int, totalEpisodes: Int? = nil, defaultBannerImage: String = "", - module: ScrapingModule, parentTitle: String, showPosterURL: String? = nil, - isMultiSelectMode: Bool = false, isSelected: Bool = false, - onSelectionChanged: ((Bool) -> Void)? = nil, - onTap: @escaping (String) -> Void, onMarkAllPrevious: @escaping () -> Void, - tmdbID: Int? = nil, - seasonNumber: Int? = nil + init( + episodeIndex: Int, + episode: String, + episodeID: Int, + progress: Double, + itemID: Int, + totalEpisodes: Int? = nil, + defaultBannerImage: String = "", + module: ScrapingModule, + parentTitle: String, + showPosterURL: String? = nil, + isMultiSelectMode: Bool = false, + isSelected: Bool = false, + onSelectionChanged: ((Bool) -> Void)? = nil, + onTap: @escaping (String) -> Void, + onMarkAllPrevious: @escaping () -> Void, + tmdbID: Int? = nil, + seasonNumber: Int? = nil ) { self.episodeIndex = episodeIndex self.episode = episode @@ -123,16 +87,6 @@ struct EpisodeCell: View { self.progress = progress self.itemID = itemID self.totalEpisodes = totalEpisodes - - let isLightMode = (UserDefaults.standard.string(forKey: "selectedAppearance") == "light") || - ((UserDefaults.standard.string(forKey: "selectedAppearance") == "system") && - UITraitCollection.current.userInterfaceStyle == .light) - let defaultLightBanner = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png" - let defaultDarkBanner = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner2.png" - - self.defaultBannerImage = defaultBannerImage.isEmpty ? - (isLightMode ? defaultLightBanner : defaultDarkBanner) : defaultBannerImage - self.module = module self.parentTitle = parentTitle self.showPosterURL = showPosterURL @@ -143,147 +97,37 @@ struct EpisodeCell: View { self.onMarkAllPrevious = onMarkAllPrevious self.tmdbID = tmdbID self.seasonNumber = seasonNumber + + + let isLightMode = (UserDefaults.standard.string(forKey: "selectedAppearance") == "light") || + ((UserDefaults.standard.string(forKey: "selectedAppearance") == "system") && + UITraitCollection.current.userInterfaceStyle == .light) + + let defaultLightBanner = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner1.png" + let defaultDarkBanner = "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/assets/banner2.png" + + self.defaultBannerImage = defaultBannerImage.isEmpty ? + (isLightMode ? defaultLightBanner : defaultDarkBanner) : defaultBannerImage } + var body: some View { ZStack { - HStack { - Spacer() - actionButtons - } - .zIndex(0) + + actionButtonsBackground - HStack { - episodeThumbnail - episodeInfo - Spacer() - CircularProgressBar(progress: currentProgress) - .frame(width: 40, height: 40) - .padding(.trailing, 4) - } - .contentShape(Rectangle()) - .padding(.horizontal, 8) - .padding(.vertical, 8) - .frame(maxWidth: .infinity) - .background( - RoundedRectangle(cornerRadius: 15) - .fill(Color(UIColor.systemBackground)) - .overlay( - RoundedRectangle(cornerRadius: 15) - .fill(Color.gray.opacity(0.2)) - ) - .overlay( - RoundedRectangle(cornerRadius: 15) - .stroke( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color.accentColor.opacity(0.25), location: 0), - .init(color: Color.accentColor.opacity(0), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.5 - ) - ) - ) - .clipShape(RoundedRectangle(cornerRadius: 15)) - .offset(x: swipeOffset) - .zIndex(1) - .animation(.spring(response: 0.3, dampingFraction: 0.8), value: swipeOffset) - .contextMenu { - contextMenuContent - } - .highPriorityGesture( - DragGesture(minimumDistance: 10) - .onChanged { value in - let horizontalTranslation = value.translation.width - let verticalTranslation = value.translation.height - - // Only handle if it's a clear horizontal swipe - if abs(horizontalTranslation) > abs(verticalTranslation) * 1.5 { - if horizontalTranslation < 0 { - let maxSwipe = calculateMaxSwipeDistance() - swipeOffset = max(horizontalTranslation, -maxSwipe) - } else if isShowingActions { - let maxSwipe = calculateMaxSwipeDistance() - swipeOffset = max(horizontalTranslation - maxSwipe, -maxSwipe) - } - } - } - .onEnded { value in - let horizontalTranslation = value.translation.width - let verticalTranslation = value.translation.height - - // Only handle if it was a clear horizontal swipe - if abs(horizontalTranslation) > abs(verticalTranslation) * 1.5 { - let maxSwipe = calculateMaxSwipeDistance() - let threshold = maxSwipe * 0.2 - - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - if horizontalTranslation < -threshold && !isShowingActions { - swipeOffset = -maxSwipe - isShowingActions = true - } else if horizontalTranslation > threshold && isShowingActions { - swipeOffset = 0 - isShowingActions = false - } else { - swipeOffset = isShowingActions ? -maxSwipe : 0 - } - } - } - } - ) - } - .onTapGesture { - if isShowingActions { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - swipeOffset = 0 - isShowingActions = false - } - } else if isMultiSelectMode { - onSelectionChanged?(!isSelected) - } else { - let imageUrl = episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl - onTap(imageUrl) - } - } - .onAppear { - // Configure the pan gesture - panGesture.delegate = nil - panGesture.cancelsTouchesInView = false - panGesture.delaysTouchesBegan = false - panGesture.delaysTouchesEnded = false - - updateProgress() - updateDownloadStatus() - if UserDefaults.standard.string(forKey: "metadataProviders") ?? "TMDB" == "TMDB" { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - fetchTMDBEpisodeImage() - } - } else { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - fetchAnimeEpisodeDetails() - } - } - - if let totalEpisodes = totalEpisodes, episodeID + 1 < totalEpisodes { - let nextEpisodeStart = episodeID + 1 - let count = min(5, totalEpisodes - episodeID - 1) - } - } - .onDisappear { - activeDownloadTask = nil - } - .onChange(of: progress) { _ in - updateProgress() - } - .onChange(of: itemID) { newID in - loadedFromCache = false - isLoading = true - retryAttempts = maxRetryAttempts - fetchEpisodeDetails() + + episodeCellContent + .offset(x: swipeOffset) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: swipeOffset) + .contextMenu { contextMenuContent } + .gesture(swipeGesture) + .onTapGesture { handleTap() } } + .onAppear { setupOnAppear() } + .onDisappear { activeDownloadTask = nil } + .onChange(of: progress) { _ in updateProgress() } + .onChange(of: itemID) { _ in handleItemIDChange() } .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadProgressChanged"))) { _ in DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { updateDownloadStatus() @@ -300,38 +144,67 @@ struct EpisodeCell: View { updateProgress() } } +} + + +private extension EpisodeCell { - private var episodeThumbnail: some View { + var actionButtonsBackground: some View { + HStack { + Spacer() + actionButtons + } + .zIndex(0) + } + + var episodeCellContent: some View { + HStack { + episodeThumbnail + episodeInfo + Spacer() + CircularProgressBar(progress: currentProgress) + .frame(width: 40, height: 40) + .padding(.trailing, 4) + } + .contentShape(Rectangle()) + .padding(.horizontal, 8) + .padding(.vertical, 8) + .frame(maxWidth: .infinity) + .background(cellBackground) + .clipShape(RoundedRectangle(cornerRadius: 15)) + .zIndex(1) + } + + var cellBackground: some View { + RoundedRectangle(cornerRadius: 15) + .fill(Color(UIColor.systemBackground)) + .overlay( + RoundedRectangle(cornerRadius: 15) + .fill(Color.gray.opacity(0.2)) + ) + .overlay( + RoundedRectangle(cornerRadius: 15) + .stroke( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color.accentColor.opacity(0.25), location: 0), + .init(color: Color.accentColor.opacity(0), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 0.5 + ) + ) + } + + var episodeThumbnail: some View { ZStack { - if let url = URL(string: episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl) { - LazyImage(url: url) { state in - if let image = state.imageContainer?.image { - Image(uiImage: image) - .resizable() - .aspectRatio(16/9, contentMode: .fill) - .frame(width: 100, height: 56) - .cornerRadius(8) - } else if state.error != nil { - Rectangle() - .fill(.tertiary) - .frame(width: 100, height: 56) - .cornerRadius(8) - .onAppear { - Logger.shared.log("Failed to load episode image: \(state.error?.localizedDescription ?? "Unknown error")", type: "Error") - } - } else { - Rectangle() - .fill(.tertiary) - .frame(width: 100, height: 56) - .cornerRadius(8) - } - } - } else { - Rectangle() - .fill(Color.gray.opacity(0.3)) - .frame(width: 100, height: 56) - .cornerRadius(8) - } + AsyncImageView( + url: episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl, + width: 100, + height: 56 + ) if isLoading { ProgressView() @@ -340,10 +213,11 @@ struct EpisodeCell: View { } } - private var episodeInfo: some View { + var episodeInfo: some View { VStack(alignment: .leading) { Text("Episode \(episodeID + 1)") .font(.system(size: 15)) + if !episodeTitle.isEmpty { Text(episodeTitle) .font(.system(size: 13)) @@ -352,78 +226,7 @@ struct EpisodeCell: View { } } - private var downloadStatusView: some View { - Group { - switch downloadStatus { - case .notDownloaded: - downloadButton - case .downloading(let activeDownload): - if activeDownload.queueStatus == .queued { - queuedIndicator - } else { - downloadProgressView - } - case .downloaded: - downloadedIndicator - } - } - } - - private var downloadButton: some View { - Button(action: { - showDownloadConfirmation = true - }) { - Image(systemName: "arrow.down.circle") - .foregroundColor(.blue) - .font(.title3) - } - .padding(.horizontal, 8) - } - - private var downloadProgressView: some View { - HStack(spacing: 4) { - Image(systemName: "arrow.down.circle.fill") - .foregroundColor(.blue) - .font(.title3) - .scaleEffect(downloadAnimationScale) - .onAppear { - withAnimation( - Animation.easeInOut(duration: 1.0).repeatForever(autoreverses: true) - ) { - downloadAnimationScale = 1.2 - } - } - .onDisappear { - downloadAnimationScale = 1.0 - } - } - .padding(.horizontal, 8) - } - - private var downloadedIndicator: some View { - Image(systemName: "checkmark.circle.fill") - .foregroundColor(.green) - .font(.title3) - .padding(.horizontal, 8) - .scaleEffect(1.1) - .animation(.default, value: downloadStatusString) - } - - private var queuedIndicator: some View { - HStack(spacing: 4) { - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) - .scaleEffect(0.8) - .accentColor(.orange) - - Text("Queued") - .font(.caption) - .foregroundColor(.secondary) - } - .padding(.horizontal, 8) - } - - private var contextMenuContent: some View { + var contextMenuContent: some View { Group { if progress <= 0.9 { Button(action: markAsWatched) { @@ -449,7 +252,170 @@ struct EpisodeCell: View { } } - private func updateDownloadStatus() { + var actionButtons: some View { + HStack(spacing: 8) { + ActionButton( + icon: "arrow.down.circle", + label: "Download", + color: .blue, + width: actionButtonWidth + ) { + closeActionsAndPerform { downloadEpisode() } + } + + if progress <= 0.9 { + ActionButton( + icon: "checkmark.circle", + label: "Watched", + color: .green, + width: actionButtonWidth + ) { + closeActionsAndPerform { markAsWatched() } + } + } + + if progress != 0 { + ActionButton( + icon: "arrow.counterclockwise", + label: "Reset", + color: .orange, + width: actionButtonWidth + ) { + closeActionsAndPerform { resetProgress() } + } + } + + if episodeIndex > 0 { + ActionButton( + icon: "checkmark.circle.fill", + label: "All Prev", + color: .purple, + width: actionButtonWidth + ) { + closeActionsAndPerform { onMarkAllPrevious() } + } + } + } + .padding(.horizontal, 8) + } +} + + +private extension EpisodeCell { + + var swipeGesture: some Gesture { + DragGesture(minimumDistance: 10) + .onChanged { value in + handleSwipeChanged(value) + } + .onEnded { value in + handleSwipeEnded(value) + } + } + + func handleSwipeChanged(_ value: DragGesture.Value) { + let horizontalTranslation = value.translation.width + let verticalTranslation = value.translation.height + + guard abs(horizontalTranslation) > abs(verticalTranslation) * 1.5 else { return } + + if horizontalTranslation < 0 { + let maxSwipe = calculateMaxSwipeDistance() + swipeOffset = max(horizontalTranslation, -maxSwipe) + } else if isShowingActions { + let maxSwipe = calculateMaxSwipeDistance() + swipeOffset = max(horizontalTranslation - maxSwipe, -maxSwipe) + } + } + + func handleSwipeEnded(_ value: DragGesture.Value) { + let horizontalTranslation = value.translation.width + let verticalTranslation = value.translation.height + + guard abs(horizontalTranslation) > abs(verticalTranslation) * 1.5 else { return } + + let maxSwipe = calculateMaxSwipeDistance() + let threshold = maxSwipe * 0.2 + + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + if horizontalTranslation < -threshold && !isShowingActions { + swipeOffset = -maxSwipe + isShowingActions = true + } else if horizontalTranslation > threshold && isShowingActions { + swipeOffset = 0 + isShowingActions = false + } else { + swipeOffset = isShowingActions ? -maxSwipe : 0 + } + } + } + + func handleTap() { + if isShowingActions { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + swipeOffset = 0 + isShowingActions = false + } + } else if isMultiSelectMode { + onSelectionChanged?(!isSelected) + } else { + let imageUrl = episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl + onTap(imageUrl) + } + } + + func calculateMaxSwipeDistance() -> CGFloat { + var buttonCount = 1 + + if progress <= 0.9 { buttonCount += 1 } + if progress != 0 { buttonCount += 1 } + if episodeIndex > 0 { buttonCount += 1 } + + var swipeDistance = CGFloat(buttonCount) * actionButtonWidth + 16 + + if buttonCount == 3 { swipeDistance += 12 } + else if buttonCount == 4 { swipeDistance += 24 } + + return swipeDistance + } +} + +private extension EpisodeCell { + + func closeActionsAndPerform(action: @escaping () -> Void) { + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + isShowingActions = false + swipeOffset = 0 + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { + action() + } + } + + func markAsWatched() { + let userDefaults = UserDefaults.standard + let totalTime = 1000.0 + userDefaults.set(totalTime, forKey: "lastPlayedTime_\(episode)") + userDefaults.set(totalTime, forKey: "totalTime_\(episode)") + updateProgress() + } + + func resetProgress() { + let userDefaults = UserDefaults.standard + userDefaults.set(0.0, forKey: "lastPlayedTime_\(episode)") + userDefaults.set(0.0, forKey: "totalTime_\(episode)") + updateProgress() + } + + func updateProgress() { + let userDefaults = UserDefaults.standard + let lastPlayedTime = userDefaults.double(forKey: "lastPlayedTime_\(episode)") + let totalTime = userDefaults.double(forKey: "totalTime_\(episode)") + currentProgress = totalTime > 0 ? min(lastPlayedTime / totalTime, 1.0) : 0 + } + + func updateDownloadStatus() { let newStatus = jsController.isEpisodeDownloadedOrInProgress( showTitle: parentTitle, episodeNumber: episodeID + 1 @@ -459,47 +425,82 @@ struct EpisodeCell: View { downloadStatus = newStatus } } +} + +private extension EpisodeCell { - private func downloadEpisode() { + func setupOnAppear() { + updateProgress() updateDownloadStatus() - if case .notDownloaded = downloadStatus, !isDownloading { - isDownloading = true - let downloadID = UUID() - - DropManager.shared.downloadStarted(episodeNumber: episodeID + 1) - - Task { - do { - let jsContent = try moduleManager.getModuleContent(module) - jsController.loadScript(jsContent) - tryNextDownloadMethod(methodIndex: 0, downloadID: downloadID, softsub: module.metadata.softsub == true) - } catch { - DropManager.shared.error("Failed to start download: \(error.localizedDescription)") - isDownloading = false - } - } - } else { - if case .downloaded = downloadStatus { - DropManager.shared.info("Episode \(episodeID + 1) is already downloaded") - } else if case .downloading = downloadStatus { - DropManager.shared.info("Episode \(episodeID + 1) is already being downloaded") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + if UserDefaults.standard.string(forKey: "metadataProviders") ?? "TMDB" == "TMDB" { + fetchTMDBEpisodeImage() + } else { + fetchAnimeEpisodeDetails() } } } - private func tryNextDownloadMethod(methodIndex: Int, downloadID: UUID, softsub: Bool) { - if !isDownloading { + func handleItemIDChange() { + isLoading = true + retryAttempts = 0 + fetchEpisodeDetails() + } + + func fetchEpisodeDetails() { + fetchAnimeEpisodeDetails() + } +} + +private extension EpisodeCell { + + func downloadEpisode() { + updateDownloadStatus() + + guard case .notDownloaded = downloadStatus, !isDownloading else { + handleAlreadyDownloadedOrInProgress() return } + isDownloading = true + let downloadID = UUID() + + DropManager.shared.downloadStarted(episodeNumber: episodeID + 1) + + Task { + do { + let jsContent = try moduleManager.getModuleContent(module) + jsController.loadScript(jsContent) + tryNextDownloadMethod(methodIndex: 0, downloadID: downloadID, softsub: module.metadata.softsub == true) + } catch { + DropManager.shared.error("Failed to start download: \(error.localizedDescription)") + isDownloading = false + } + } + } + + func handleAlreadyDownloadedOrInProgress() { + switch downloadStatus { + case .downloaded: + DropManager.shared.info("Episode \(episodeID + 1) is already downloaded") + case .downloading: + DropManager.shared.info("Episode \(episodeID + 1) is already being downloaded") + case .notDownloaded: + break + } + } + + func tryNextDownloadMethod(methodIndex: Int, downloadID: UUID, softsub: Bool) { + guard isDownloading else { return } + print("[Download] Trying download method #\(methodIndex+1) for Episode \(episodeID + 1)") switch methodIndex { case 0: if module.metadata.asyncJS == true { jsController.fetchStreamUrlJS(episodeUrl: episode, softsub: softsub, module: module) { result in - self.handleSequentialDownloadResult(result, downloadID: downloadID, methodIndex: methodIndex, softsub: softsub) + self.handleDownloadResult(result, downloadID: downloadID, methodIndex: methodIndex, softsub: softsub) } } else { tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub) @@ -508,7 +509,7 @@ struct EpisodeCell: View { case 1: if module.metadata.streamAsyncJS == true { jsController.fetchStreamUrlJSSecond(episodeUrl: episode, softsub: softsub, module: module) { result in - self.handleSequentialDownloadResult(result, downloadID: downloadID, methodIndex: methodIndex, softsub: softsub) + self.handleDownloadResult(result, downloadID: downloadID, methodIndex: methodIndex, softsub: softsub) } } else { tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub) @@ -516,7 +517,7 @@ struct EpisodeCell: View { case 2: jsController.fetchStreamUrl(episodeUrl: episode, softsub: softsub, module: module) { result in - self.handleSequentialDownloadResult(result, downloadID: downloadID, methodIndex: methodIndex, softsub: softsub) + self.handleDownloadResult(result, downloadID: downloadID, methodIndex: methodIndex, softsub: softsub) } default: @@ -525,23 +526,21 @@ struct EpisodeCell: View { } } - private func handleSequentialDownloadResult(_ result: (streams: [String]?, subtitles: [String]?, sources: [[String:Any]]?), downloadID: UUID, methodIndex: Int, softsub: Bool) { - if !isDownloading { - return - } + func handleDownloadResult( + _ result: (streams: [String]?, subtitles: [String]?, sources: [[String: Any]]?), + downloadID: UUID, + methodIndex: Int, + softsub: Bool + ) { + guard isDownloading else { return } if let sources = result.sources, !sources.isEmpty { if sources.count > 1 { showDownloadStreamSelectionAlert(streams: sources, downloadID: downloadID, subtitleURL: result.subtitles?.first) return } else if let streamUrl = sources[0]["streamUrl"] as? String, let url = URL(string: streamUrl) { - let subtitleURLString = sources[0]["subtitle"] as? String let subtitleURL = subtitleURLString.flatMap { URL(string: $0) } - if let subtitleURL = subtitleURL { - Logger.shared.log("[Download] Found subtitle URL: \(subtitleURL.absoluteString)") - } - startActualDownload(url: url, streamUrl: streamUrl, downloadID: downloadID, subtitleURL: subtitleURL) return } @@ -558,10 +557,6 @@ struct EpisodeCell: View { return } else if let url = URL(string: streams[0]) { let subtitleURL = result.subtitles?.first.flatMap { URL(string: $0) } - if let subtitleURL = subtitleURL { - Logger.shared.log("[Download] Found subtitle URL: \(subtitleURL.absoluteString)") - } - startActualDownload(url: url, streamUrl: streams[0], downloadID: downloadID, subtitleURL: subtitleURL) return } @@ -570,145 +565,13 @@ struct EpisodeCell: View { tryNextDownloadMethod(methodIndex: methodIndex + 1, downloadID: downloadID, softsub: softsub) } - private func showDownloadStreamSelectionAlert(streams: [Any], downloadID: UUID, subtitleURL: String? = nil) { - DispatchQueue.main.async { - let alert = UIAlertController(title: "Select Download Server", message: "Choose a server to download from", preferredStyle: .actionSheet) - - var index = 0 - var streamIndex = 1 - - while index < streams.count { - var title: String = "" - var streamUrl: String = "" - - if let streams = streams as? [String] { - if index + 1 < streams.count { - if !streams[index].lowercased().contains("http") { - title = streams[index] - streamUrl = streams[index + 1] - index += 2 - } else { - title = "Server \(streamIndex)" - streamUrl = streams[index] - index += 1 - } - } else { - title = "Server \(streamIndex)" - streamUrl = streams[index] - index += 1 - } - } else if let streams = streams as? [[String: Any]] { - if let currTitle = streams[index]["title"] as? String { - title = currTitle - } else { - title = "Server \(streamIndex)" - } - streamUrl = (streams[index]["streamUrl"] as? String) ?? "" - index += 1 - } - - alert.addAction(UIAlertAction(title: title, style: .default) { _ in - guard let url = URL(string: streamUrl) else { - DropManager.shared.error("Invalid stream URL selected") - self.isDownloading = false - return - } - - let subtitleURLObj = subtitleURL.flatMap { URL(string: $0) } - self.startActualDownload(url: url, streamUrl: streamUrl, downloadID: downloadID, subtitleURL: subtitleURLObj) - }) - - streamIndex += 1 - } - - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in - self.isDownloading = false - }) - - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let rootVC = window.rootViewController { - - if UIDevice.current.userInterfaceIdiom == .pad { - if let popover = alert.popoverPresentationController { - popover.sourceView = window - popover.sourceRect = CGRect( - x: UIScreen.main.bounds.width / 2, - y: UIScreen.main.bounds.height / 2, - width: 0, - height: 0 - ) - popover.permittedArrowDirections = [] - } - } - - self.findTopViewController(rootVC).present(alert, animated: true) - } - } - } - - private func findTopViewController(_ controller: UIViewController) -> UIViewController { - if let navigationController = controller as? UINavigationController { - return findTopViewController(navigationController.visibleViewController!) - } - if let tabController = controller as? UITabBarController { - if let selected = tabController.selectedViewController { - return findTopViewController(selected) - } - } - if let presented = controller.presentedViewController { - return findTopViewController(presented) - } - return controller - } - - private func startActualDownload(url: URL, streamUrl: String, downloadID: UUID, subtitleURL: URL? = nil) { - var headers: [String: String] = [:] - - if !module.metadata.baseUrl.isEmpty && !module.metadata.baseUrl.contains("undefined") { - print("Using module baseUrl: \(module.metadata.baseUrl)") - - headers = [ - "Origin": module.metadata.baseUrl, - "Referer": module.metadata.baseUrl, - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", - "Accept": "*/*", - "Accept-Language": "en-US,en;q=0.9", - "Sec-Fetch-Dest": "empty", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Site": "same-origin" - ] - } else { - if let scheme = url.scheme, let host = url.host { - let baseUrl = scheme + "://" + host - - headers = [ - "Origin": baseUrl, - "Referer": baseUrl, - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", - "Accept": "*/*", - "Accept-Language": "en-US,en;q=0.9", - "Sec-Fetch-Dest": "empty", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Site": "same-origin" - ] - } else { - DropManager.shared.error("Invalid stream URL - missing scheme or host") - isDownloading = false - return - } - } - - print("Download headers: \(headers)") - + func startActualDownload(url: URL, streamUrl: String, downloadID: UUID, subtitleURL: URL? = nil) { + let headers = createDownloadHeaders(for: url) let episodeThumbnailURL = URL(string: episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl) let showPosterImageURL = URL(string: showPosterURL ?? defaultBannerImage) let baseTitle = "Episode \(episodeID + 1)" - let fullEpisodeTitle = episodeTitle.isEmpty - ? baseTitle - : "\(baseTitle): \(episodeTitle)" - + let fullEpisodeTitle = episodeTitle.isEmpty ? baseTitle : "\(baseTitle): \(episodeTitle)" let animeTitle = parentTitle.isEmpty ? "Unknown Anime" : parentTitle jsController.downloadWithStreamTypeSupport( @@ -722,54 +585,152 @@ struct EpisodeCell: View { season: 1, episode: episodeID + 1, subtitleURL: subtitleURL, - showPosterURL: showPosterImageURL, - completionHandler: { success, message in - if success { - Logger.shared.log("Started download for Episode \(self.episodeID + 1): \(self.episode)", type: "Download") - AnalyticsManager.shared.sendEvent( - event: "download", - additionalData: ["episode": self.episodeID + 1, "url": streamUrl] - ) - } else { - DropManager.shared.error(message) - } - self.isDownloading = false + showPosterURL: showPosterImageURL + ) { success, message in + if success { + Logger.shared.log("Started download for Episode \(self.episodeID + 1): \(self.episode)", type: "Download") + AnalyticsManager.shared.sendEvent( + event: "download", + additionalData: ["episode": self.episodeID + 1, "url": streamUrl] + ) + } else { + DropManager.shared.error(message) } - ) - } - - private func markAsWatched() { - let userDefaults = UserDefaults.standard - let totalTime = 1000.0 - let watchedTime = totalTime - userDefaults.set(watchedTime, forKey: "lastPlayedTime_\(episode)") - userDefaults.set(totalTime, forKey: "totalTime_\(episode)") - DispatchQueue.main.async { - self.updateProgress() + self.isDownloading = false } } - private func resetProgress() { - let userDefaults = UserDefaults.standard - userDefaults.set(0.0, forKey: "lastPlayedTime_\(episode)") - userDefaults.set(0.0, forKey: "totalTime_\(episode)") + func createDownloadHeaders(for url: URL) -> [String: String] { + if !module.metadata.baseUrl.isEmpty && !module.metadata.baseUrl.contains("undefined") { + return [ + "Origin": module.metadata.baseUrl, + "Referer": module.metadata.baseUrl, + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.9", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin" + ] + } else if let scheme = url.scheme, let host = url.host { + let baseUrl = "\(scheme)://\(host)" + return [ + "Origin": baseUrl, + "Referer": baseUrl, + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.9", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin" + ] + } else { + DropManager.shared.error("Invalid stream URL - missing scheme or host") + isDownloading = false + return [:] + } + } +} + +private extension EpisodeCell { + + func showDownloadStreamSelectionAlert(streams: [Any], downloadID: UUID, subtitleURL: String? = nil) { DispatchQueue.main.async { - self.updateProgress() + let alert = UIAlertController( + title: "Select Download Server", + message: "Choose a server to download from", + preferredStyle: .actionSheet + ) + + addStreamActions(to: alert, streams: streams, downloadID: downloadID, subtitleURL: subtitleURL) + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in + self.isDownloading = false + }) + + presentAlert(alert) } } - private func updateProgress() { - let userDefaults = UserDefaults.standard - let lastPlayedTime = userDefaults.double(forKey: "lastPlayedTime_\(episode)") - let totalTime = userDefaults.double(forKey: "totalTime_\(episode)") - currentProgress = totalTime > 0 ? min(lastPlayedTime / totalTime, 1.0) : 0 + func addStreamActions(to alert: UIAlertController, streams: [Any], downloadID: UUID, subtitleURL: String?) { + var index = 0 + var streamIndex = 1 + + while index < streams.count { + let (title, streamUrl, newIndex) = parseStreamInfo(streams: streams, index: index, streamIndex: streamIndex) + index = newIndex + + alert.addAction(UIAlertAction(title: title, style: .default) { _ in + guard let url = URL(string: streamUrl) else { + DropManager.shared.error("Invalid stream URL selected") + self.isDownloading = false + return + } + + let subtitleURLObj = subtitleURL.flatMap { URL(string: $0) } + self.startActualDownload(url: url, streamUrl: streamUrl, downloadID: downloadID, subtitleURL: subtitleURLObj) + }) + + streamIndex += 1 + } } - private func fetchEpisodeDetails() { - fetchAnimeEpisodeDetails() + func parseStreamInfo(streams: [Any], index: Int, streamIndex: Int) -> (title: String, streamUrl: String, newIndex: Int) { + if let streams = streams as? [String] { + if index + 1 < streams.count && !streams[index].lowercased().contains("http") { + return (streams[index], streams[index + 1], index + 2) + } else { + return ("Server \(streamIndex)", streams[index], index + 1) + } + } else if let streams = streams as? [[String: Any]] { + let title = streams[index]["title"] as? String ?? "Server \(streamIndex)" + let streamUrl = streams[index]["streamUrl"] as? String ?? "" + return (title, streamUrl, index + 1) + } + + return ("Server \(streamIndex)", "", index + 1) } - private func fetchAnimeEpisodeDetails() { + func presentAlert(_ alert: UIAlertController) { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let rootVC = window.rootViewController else { return } + + if UIDevice.current.userInterfaceIdiom == .pad { + if let popover = alert.popoverPresentationController { + popover.sourceView = window + popover.sourceRect = CGRect( + x: UIScreen.main.bounds.width / 2, + y: UIScreen.main.bounds.height / 2, + width: 0, + height: 0 + ) + popover.permittedArrowDirections = [] + } + } + + findTopViewController(rootVC).present(alert, animated: true) + } + + func findTopViewController(_ controller: UIViewController) -> UIViewController { + if let navigationController = controller as? UINavigationController { + return findTopViewController(navigationController.visibleViewController!) + } + if let tabController = controller as? UITabBarController { + if let selected = tabController.selectedViewController { + return findTopViewController(selected) + } + } + if let presented = controller.presentedViewController { + return findTopViewController(presented) + } + return controller + } +} + +private extension EpisodeCell { + + func fetchAnimeEpisodeDetails() { guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(itemID)") else { isLoading = false Logger.shared.log("Invalid URL for itemID: \(itemID)", type: "Error") @@ -788,129 +749,87 @@ struct EpisodeCell: View { } guard let data = data else { - self.handleFetchFailure(error: NSError(domain: "com.sora.episode", code: 1, userInfo: [NSLocalizedDescriptionKey: "No data received"])) + self.handleFetchFailure(error: NetworkError.noData) return } - do { - let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) - guard let json = jsonObject as? [String: Any] else { - self.handleFetchFailure(error: NSError(domain: "com.sora.episode", code: 2, userInfo: [NSLocalizedDescriptionKey: "Invalid JSON format"])) - return - } - - guard let episodes = json["episodes"] as? [String: Any] else { - Logger.shared.log("Missing 'episodes' object in response", type: "Error") - DispatchQueue.main.async { - self.isLoading = false - self.retryAttempts = 0 - } - return - } - - let episodeKey = "\(episodeID + 1)" - guard let episodeDetails = episodes[episodeKey] as? [String: Any] else { - Logger.shared.log("Episode \(episodeKey) not found in response", type: "Error") - DispatchQueue.main.async { - self.isLoading = false - self.retryAttempts = 0 - } - return - } - - var title: [String: String] = [:] - var image: String = "" - var missingFields: [String] = [] - - if let titleData = episodeDetails["title"] as? [String: String], !titleData.isEmpty { - title = titleData - - if title.values.allSatisfy({ $0.isEmpty }) { - missingFields.append("title (all values empty)") - } - } else { - missingFields.append("title") - } - - if let imageUrl = episodeDetails["image"] as? String, !imageUrl.isEmpty { - image = imageUrl - } else { - missingFields.append("image") - } - - if !missingFields.isEmpty { - Logger.shared.log("Episode \(episodeKey) missing fields: \(missingFields.joined(separator: ", "))", type: "Warning") - } - - DispatchQueue.main.async { - self.isLoading = false - self.retryAttempts = 0 - - if UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil - || UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata") { - self.episodeTitle = title["en"] ?? title.values.first ?? "" - - if !image.isEmpty { - self.episodeImageUrl = image - } - } - } - } catch { - Logger.shared.log("JSON parsing error: \(error.localizedDescription)", type: "Error") - DispatchQueue.main.async { - self.isLoading = false - self.retryAttempts = 0 - } - } + self.processAnimeEpisodeData(data) }.resume() } - private func handleFetchFailure(error: Error) { - Logger.shared.log("Episode details fetch error: \(error.localizedDescription)", type: "Error") - - DispatchQueue.main.async { - if self.retryAttempts < self.maxRetryAttempts { - self.retryAttempts += 1 - - let backoffDelay = self.initialBackoffDelay * pow(2.0, Double(self.retryAttempts - 1)) - - Logger.shared.log("Will retry episode details fetch in \(backoffDelay) seconds", type: "Debug") - - DispatchQueue.main.asyncAfter(deadline: .now() + backoffDelay) { - self.fetchAnimeEpisodeDetails() + func processAnimeEpisodeData(_ data: Data) { + do { + let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) + guard let json = jsonObject as? [String: Any], + let episodes = json["episodes"] as? [String: Any] else { + handleFetchFailure(error: NetworkError.invalidJSON) + return + } + + let episodeKey = "\(episodeID + 1)" + guard let episodeDetails = episodes[episodeKey] as? [String: Any] else { + Logger.shared.log("Episode \(episodeKey) not found in response", type: "Error") + DispatchQueue.main.async { + self.isLoading = false + self.retryAttempts = 0 } - } else { - Logger.shared.log("Failed to fetch episode details after \(self.maxRetryAttempts) attempts", type: "Error") + return + } + + updateEpisodeMetadata(from: episodeDetails) + + } catch { + Logger.shared.log("JSON parsing error: \(error.localizedDescription)", type: "Error") + DispatchQueue.main.async { self.isLoading = false self.retryAttempts = 0 } } } - private func fetchTMDBEpisodeImage() { + func updateEpisodeMetadata(from episodeDetails: [String: Any]) { + let title = episodeDetails["title"] as? [String: String] ?? [:] + let image = episodeDetails["image"] as? String ?? "" + + DispatchQueue.main.async { + self.isLoading = false + self.retryAttempts = 0 + + if UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil || + UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata") { + self.episodeTitle = title["en"] ?? title.values.first ?? "" + + if !image.isEmpty { + self.episodeImageUrl = image + } + } + } + } + + func fetchTMDBEpisodeImage() { guard let tmdbID = tmdbID, let season = seasonNumber else { return } + let episodeNum = episodeID + 1 let urlString = "https://api.themoviedb.org/3/tv/\(tmdbID)/season/\(season)/episode/\(episodeNum)?api_key=738b4edd0a156cc126dc4a4b8aea4aca" + guard let url = URL(string: urlString) else { return } let tmdbImageWidth = UserDefaults.standard.string(forKey: "tmdbImageWidth") ?? "original" URLSession.custom.dataTask(with: url) { data, _, error in guard let data = data, error == nil else { return } + do { if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { let name = json["name"] as? String ?? "" let stillPath = json["still_path"] as? String - let imageUrl: String - if let stillPath = stillPath { - if tmdbImageWidth == "original" { - imageUrl = "https://image.tmdb.org/t/p/original\(stillPath)" - } else { - imageUrl = "https://image.tmdb.org/t/p/w\(tmdbImageWidth)\(stillPath)" - } - } else { - imageUrl = "" - } + + let imageUrl = stillPath.map { path in + tmdbImageWidth == "original" + ? "https://image.tmdb.org/t/p/original\(path)" + : "https://image.tmdb.org/t/p/w\(tmdbImageWidth)\(path)" + } ?? "" + DispatchQueue.main.async { self.episodeTitle = name self.episodeImageUrl = imageUrl @@ -926,189 +845,97 @@ struct EpisodeCell: View { }.resume() } - private func calculateMaxSwipeDistance() -> CGFloat { - var buttonCount = 1 + func handleFetchFailure(error: Error) { + Logger.shared.log("Episode details fetch error: \(error.localizedDescription)", type: "Error") - if progress <= 0.9 { buttonCount += 1 } - if progress != 0 { buttonCount += 1 } - if episodeIndex > 0 { buttonCount += 1 } - - var swipeDistance = CGFloat(buttonCount) * actionButtonWidth + 16 - - if buttonCount == 3 { - swipeDistance += 12 - } else if buttonCount == 4 { - swipeDistance += 24 - } - - return swipeDistance - } - - private var actionButtons: some View { - HStack(spacing: 8) { - Button(action: { - closeActionsAndPerform { - downloadEpisode() - } - }) { - VStack(spacing: 2) { - Image(systemName: "arrow.down.circle") - .font(.title3) - Text("Download") - .font(.caption2) + DispatchQueue.main.async { + if self.retryAttempts < self.maxRetryAttempts { + self.retryAttempts += 1 + let backoffDelay = self.initialBackoffDelay * pow(2.0, Double(self.retryAttempts - 1)) + + Logger.shared.log("Will retry episode details fetch in \(backoffDelay) seconds", type: "Debug") + + DispatchQueue.main.asyncAfter(deadline: .now() + backoffDelay) { + self.fetchAnimeEpisodeDetails() } + } else { + Logger.shared.log("Failed to fetch episode details after \(self.maxRetryAttempts) attempts", type: "Error") + self.isLoading = false + self.retryAttempts = 0 } - .foregroundColor(.blue) - .frame(width: actionButtonWidth) - - if progress <= 0.9 { - Button(action: { - closeActionsAndPerform { - markAsWatched() - } - }) { - VStack(spacing: 2) { - Image(systemName: "checkmark.circle") - .font(.title3) - Text("Watched") - .font(.caption2) - } - } - .foregroundColor(.green) - .frame(width: actionButtonWidth) - } - - if progress != 0 { - Button(action: { - closeActionsAndPerform { - resetProgress() - } - }) { - VStack(spacing: 2) { - Image(systemName: "arrow.counterclockwise") - .font(.title3) - Text("Reset") - .font(.caption2) - } - } - .foregroundColor(.orange) - .frame(width: actionButtonWidth) - } - - if episodeIndex > 0 { - Button(action: { - closeActionsAndPerform { - onMarkAllPrevious() - } - }) { - VStack(spacing: 2) { - Image(systemName: "checkmark.circle.fill") - .font(.title3) - Text("All Prev") - .font(.caption2) - } - } - .foregroundColor(.purple) - .frame(width: actionButtonWidth) - } - } - .padding(.horizontal, 8) - } - - private func handleTap() { - if isActionsVisible { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - isActionsVisible = false - } - } else if isMultiSelectMode { - onSelectionChanged?(!isSelected) - } else { - let imageUrl = episodeImageUrl.isEmpty ? defaultBannerImage : episodeImageUrl - onTap(imageUrl) - } - } - - private func closeActionsIfNeeded() { - if isActionsVisible { - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - isActionsVisible = false - } - } - } - - private func closeActionsAndPerform(action: @escaping () -> Void) { - withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { - isActionsVisible = false - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { - action() } } } -struct UIViewWrapper: UIViewRepresentable { - let panGesture: UIPanGestureRecognizer - let onSwipe: (SwipeDirection) -> Void +private enum NetworkError: Error { + case noData + case invalidJSON - enum SwipeDirection { - case left, right, none - } - - func makeUIView(context: Context) -> UIView { - let view = UIView() - view.isUserInteractionEnabled = true - view.backgroundColor = .clear - - // Remove any existing gesture recognizers - if let existingGestures = view.gestureRecognizers { - for gesture in existingGestures { - view.removeGestureRecognizer(gesture) - } - } - - // Add the pan gesture - panGesture.addTarget(context.coordinator, action: #selector(Coordinator.handlePan(_:))) - view.addGestureRecognizer(panGesture) - - return view - } - - func updateUIView(_ uiView: UIView, context: Context) { - // Ensure the view is user interaction enabled - uiView.isUserInteractionEnabled = true - } - - func makeCoordinator() -> Coordinator { - Coordinator(onSwipe: onSwipe) - } - - class Coordinator: NSObject { - let onSwipe: (SwipeDirection) -> Void - - init(onSwipe: @escaping (SwipeDirection) -> Void) { - self.onSwipe = onSwipe - } - - @objc func handlePan(_ gesture: UIPanGestureRecognizer) { - let translation = gesture.translation(in: gesture.view) - let velocity = gesture.velocity(in: gesture.view) - - if gesture.state == .ended { - if abs(velocity.x) > abs(velocity.y) && abs(velocity.x) > 500 { - if velocity.x < 0 { - onSwipe(.left) - } else { - onSwipe(.right) - } - } else if abs(translation.x) > abs(translation.y) && abs(translation.x) > 50 { - if translation.x < 0 { - onSwipe(.left) - } else { - onSwipe(.right) - } - } - } + var localizedDescription: String { + switch self { + case .noData: + return "No data received" + case .invalidJSON: + return "Invalid JSON format" } } } + +private struct ActionButton: View { + let icon: String + let label: String + let color: Color + let width: CGFloat + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 2) { + Image(systemName: icon) + .font(.title3) + Text(label) + .font(.caption2) + } + } + .foregroundColor(color) + .frame(width: width) + } +} + +private struct AsyncImageView: View { + let url: String + let width: CGFloat + let height: CGFloat + + var body: some View { + if let url = URL(string: url) { + LazyImage(url: url) { state in + if let image = state.imageContainer?.image { + Image(uiImage: image) + .resizable() + .aspectRatio(16/9, contentMode: .fill) + .frame(width: width, height: height) + .cornerRadius(8) + } else if state.error != nil { + placeholderView + .onAppear { + Logger.shared.log("Failed to load episode image: \(state.error?.localizedDescription ?? "Unknown error")", type: "Error") + } + } else { + placeholderView + } + } + } else { + placeholderView + } + } + + private var placeholderView: some View { + Rectangle() + .fill(.tertiary) + .frame(width: width, height: height) + .cornerRadius(8) + } +} + + diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 6489ada..19386d6 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -9,6 +9,7 @@ import NukeUI import SwiftUI import SafariServices + private let tmdbFetcher = TMDBFetcher() struct MediaItem: Identifiable { @@ -18,74 +19,75 @@ struct MediaItem: Identifiable { let airdate: String } + + + struct MediaInfoView: View { let title: String @State var imageUrl: String let href: String let module: ScrapingModule - @State var aliases: String = "" - @State var synopsis: String = "" - @State var airdate: String = "" - @State var episodeLinks: [EpisodeLink] = [] - @State var itemID: Int? - @State var tmdbID: Int? + @State private var aliases: String = "" + @State private var synopsis: String = "" + @State private var airdate: String = "" + @State private var episodeLinks: [EpisodeLink] = [] + @State private var itemID: Int? + @State private var tmdbID: Int? + @State private var tmdbType: TMDBFetcher.MediaType? = nil + @State private var currentFetchTask: Task? = nil - @State var isLoading: Bool = true - @State var showFullSynopsis: Bool = false - @State var hasFetched: Bool = false - @State var isRefetching: Bool = true - @State var isFetchingEpisode: Bool = false - - @State private var refreshTrigger: Bool = false - @State private var buttonRefreshTrigger: Bool = false + @State private var isLoading: Bool = true + @State private var showFullSynopsis: Bool = false + @State private var hasFetched: Bool = false + @State private var isRefetching: Bool = true + @State private var isFetchingEpisode: Bool = false + @State private var isError = false + @State private var showLoadingAlert: Bool = false @State private var selectedEpisodeNumber: Int = 0 @State private var selectedEpisodeImage: String = "" @State private var selectedSeason: Int = 0 - - @AppStorage("externalPlayer") private var externalPlayer: String = "Default" - @AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100 - - private var selectedRangeKey: String { "selectedRangeStart_\(href)" } - private var selectedSeasonKey: String { "selectedSeason_\(href)" } @State private var selectedRange: Range = { let size = UserDefaults.standard.integer(forKey: "episodeChunkSize") let chunk = size == 0 ? 100 : size return 0.. = [] @State private var showRangeInput: Bool = false @State private var isBulkDownloading: Bool = false @State private var bulkDownloadProgress: String = "" @State private var isSingleEpisodeDownloading: Bool = false - @State private var tmdbType: TMDBFetcher.MediaType? = nil + + @State private var isModuleSelectorPresented = false + @State private var isMatchingPresented = false + @State private var matchedTitle: String? = nil + @State private var showSettingsMenu = false + @State private var customAniListID: Int? + @State private var showStreamLoadingView: Bool = false + @State private var currentStreamTitle: String = "" + @State private var activeFetchID: UUID? = nil + + @State private var refreshTrigger: Bool = false + @State private var buttonRefreshTrigger: Bool = false + + private var selectedRangeKey: String { "selectedRangeStart_\(href)" } + private var selectedSeasonKey: String { "selectedSeason_\(href)" } + + @AppStorage("externalPlayer") private var externalPlayer: String = "Default" + @AppStorage("episodeChunkSize") private var episodeChunkSize: Int = 100 + @AppStorage("selectedAppearance") private var selectedAppearance: Appearance = .system + + @ObservedObject private var jsController = JSController.shared + @EnvironmentObject var moduleManager: ModuleManager + @EnvironmentObject private var libraryManager: LibraryManager + @EnvironmentObject var tabBarController: TabBarController + + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + @Environment(\.verticalSizeClass) private var verticalSizeClass private var isGroupedBySeasons: Bool { return groupedEpisodes().count > 1 @@ -110,6 +112,32 @@ struct MediaInfoView: View { return isCompactLayout ? 20 : 16 } + private var startWatchingText: String { + let indices = finishedAndUnfinishedIndices() + let finished = indices.finished + let unfinished = indices.unfinished + + if episodeLinks.count == 1 { + if let _ = unfinished { + return "Continue Watching" + } + return "Start Watching" + } + + if let finishedIndex = finished, finishedIndex < episodeLinks.count - 1 { + let nextEp = episodeLinks[finishedIndex + 1] + return "Start Watching Episode \(nextEp.number)" + } + + if let unfinishedIndex = unfinished { + let currentEp = episodeLinks[unfinishedIndex] + return "Continue Watching Episode \(currentEp.number)" + } + + return "Start Watching" + } + + var body: some View { ZStack { Group { @@ -123,15 +151,7 @@ struct MediaInfoView: View { .navigationBarHidden(true) .ignoresSafeArea(.container, edges: .top) .onAppear { - buttonRefreshTrigger.toggle() - tabBarController.hideTabBar() - - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let navigationController = window.rootViewController?.children.first as? UINavigationController { - navigationController.interactivePopGestureRecognizer?.isEnabled = true - navigationController.interactivePopGestureRecognizer?.delegate = nil - } + setupViewOnAppear() } .onChange(of: selectedRange) { newValue in UserDefaults.standard.set(newValue.lowerBound, forKey: selectedRangeKey) @@ -139,38 +159,17 @@ struct MediaInfoView: View { .onChange(of: selectedSeason) { newValue in UserDefaults.standard.set(newValue, forKey: selectedSeasonKey) } - .onDisappear(){ + .onDisappear { tabBarController.showTabBar() + currentFetchTask?.cancel() + activeFetchID = nil } .task { - guard !hasFetched else { return } - - let savedCustomID = UserDefaults.standard.integer(forKey: "custom_anilist_id_\(href)") - if savedCustomID != 0 { customAniListID = savedCustomID } - if let savedPoster = UserDefaults.standard.string(forKey: "tmdbPosterURL_\(href)") { - imageUrl = savedPoster - } - - DropManager.shared.showDrop(title: "Fetching Data", subtitle: "Please wait while fetching.", duration: 0.5, icon: UIImage(systemName: "arrow.triangle.2.circlepath")) - fetchDetails() - - if savedCustomID != 0 { - itemID = savedCustomID - } else { - fetchMetadataIDIfNeeded() - } - - hasFetched = true - AnalyticsManager.shared.sendEvent( - event: "MediaInfoView", - additionalData: ["title": title] - ) + await setupInitialData() } .alert("Loading Stream", isPresented: $showLoadingAlert) { Button("Cancel", role: .cancel) { - activeFetchID = nil - isFetchingEpisode = false - showStreamLoadingView = false + cancelCurrentFetch() } } message: { HStack { @@ -179,32 +178,32 @@ struct MediaInfoView: View { .padding(.top, 8) } } - .onDisappear { - activeFetchID = nil - isFetchingEpisode = false - showStreamLoadingView = false - } - VStack { - HStack { - Button(action: { - dismiss() - }) { - Image(systemName: "chevron.left") - .font(.system(size: 24)) - .foregroundColor(.primary) - .padding(12) - .background(Color.gray.opacity(0.2)) - .clipShape(Circle()) - .circularGradientOutline() - } - .padding(.top, 8) - .padding(.leading, 16) - - Spacer() + navigationOverlay + } + } + + // MARK: - View Builders + + @ViewBuilder + private var navigationOverlay: some View { + VStack { + HStack { + Button(action: { dismiss() }) { + Image(systemName: "chevron.left") + .font(.system(size: 24)) + .foregroundColor(.primary) + .padding(12) + .background(Color.gray.opacity(0.2)) + .clipShape(Circle()) + .circularGradientOutline() } + .padding(.top, 8) + .padding(.leading, 16) + Spacer() } + Spacer() } } @@ -212,53 +211,8 @@ struct MediaInfoView: View { private var mainScrollView: some View { ScrollView { ZStack(alignment: .top) { - LazyImage(url: URL(string: imageUrl)) { state in - if let uiImage = state.imageContainer?.image { - Image(uiImage: uiImage) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: UIScreen.main.bounds.width, height: 700) - .clipped() - } else { - Rectangle() - .fill(Color.gray.opacity(0.3)) - .shimmering() - .frame(width: UIScreen.main.bounds.width, height: 700) - .clipped() - } - } - - VStack(spacing: 0) { - Rectangle() - .fill(Color.clear) - .frame(height: 400) - - ZStack(alignment: .top) { - LinearGradient( - gradient: Gradient(stops: [ - .init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.0), location: 0.0), - .init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.5), location: 0.2), - .init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.8), location: 0.5), - .init(color: (colorScheme == .dark ? Color.black : Color.white), location: 1.0) - ]), - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 300) - .clipShape(RoundedRectangle(cornerRadius: 0)) - .shadow(color: (colorScheme == .dark ? Color.black : Color.white).opacity(1), radius: 10, x: 0, y: 10) - - VStack(alignment: .leading, spacing: 16) { - headerSection - if !episodeLinks.isEmpty { - episodesSection - } else { - noEpisodesSection - } - } - .padding() - } - } + heroImageSection + contentContainer } } .onAppear { @@ -266,300 +220,132 @@ struct MediaInfoView: View { } } + @ViewBuilder + private var heroImageSection: some View { + LazyImage(url: URL(string: imageUrl)) { state in + if let uiImage = state.imageContainer?.image { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: UIScreen.main.bounds.width, height: 700) + .clipped() + } else { + Rectangle() + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color.gray.opacity(0.2), + Color.gray.opacity(0.3), + Color.gray.opacity(0.2) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: UIScreen.main.bounds.width, height: 700) + .clipped() + } + } + } + + @ViewBuilder + private var contentContainer: some View { + VStack(spacing: 0) { + Rectangle() + .fill(Color.clear) + .frame(height: 400) + + ZStack(alignment: .top) { + gradientOverlay + + VStack(alignment: .leading, spacing: 16) { + headerSection + if !episodeLinks.isEmpty { + episodesSection + } else { + noEpisodesSection + } + } + .padding() + } + } + } + + @ViewBuilder + private var gradientOverlay: some View { + LinearGradient( + gradient: Gradient(stops: [ + .init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.0), location: 0.0), + .init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.5), location: 0.2), + .init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.8), location: 0.5), + .init(color: (colorScheme == .dark ? Color.black : Color.white), location: 1.0) + ]), + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 300) + .clipShape(RoundedRectangle(cornerRadius: 0)) + .shadow(color: (colorScheme == .dark ? Color.black : Color.white).opacity(1), radius: 10, x: 0, y: 10) + } + @ViewBuilder private var headerSection: some View { VStack(alignment: .leading, spacing: 8) { Spacer() - HStack(spacing: 16) { - - if !airdate.isEmpty && airdate != "N/A" && airdate != "No Data" { - HStack(spacing: 4) { - Image(systemName: "calendar") - .foregroundColor(.accentColor) - - Text(airdate) - .font(.system(size: 14)) - .foregroundColor(.accentColor) - } + + // Airdate section + if !airdate.isEmpty && airdate != "N/A" && airdate != "No Data" { + HStack(spacing: 4) { + Image(systemName: "calendar") + .foregroundColor(.accentColor) + Text(airdate) + .font(.system(size: 14)) + .foregroundColor(.accentColor) + Spacer() } - - Spacer() - } + + // Title with copy gesture Text(title) .font(.system(size: 28, weight: .bold)) .foregroundColor(.primary) .lineLimit(3) .onLongPressGesture { - UIPasteboard.general.string = title - DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill")) + copyTitleToClipboard() } + // Synopsis with expand/collapse if !synopsis.isEmpty { - HStack(alignment: .bottom) { - Text(synopsis) - .font(.system(size: 16)) - .foregroundColor(.secondary) - .lineLimit(showFullSynopsis ? nil : 3) - .animation(nil, value: showFullSynopsis) - - Text(showFullSynopsis ? "LESS" : "MORE") - .font(.system(size: 16, weight: .bold)) - .foregroundColor(.accentColor) - .animation(.easeInOut(duration: 0.3), value: showFullSynopsis) - } - .onTapGesture { - withAnimation(.easeInOut(duration: 0.3)) { - showFullSynopsis.toggle() - } - } + synopsisSection } + // Main action buttons playAndBookmarkSection + // Single episode special handling if episodeLinks.count == 1 { - VStack(spacing: 12) { - HStack(spacing: 12) { - Button(action: { - if let ep = episodeLinks.first { - let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)") - let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)") - let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0 - - if progress <= 0.9 { - UserDefaults.standard.set(99999999.0, forKey: "lastPlayedTime_\(ep.href)") - UserDefaults.standard.set(99999999.0, forKey: "totalTime_\(ep.href)") - DropManager.shared.showDrop(title: "Marked as Watched", subtitle: "", duration: 1.0, icon: UIImage(systemName: "checkmark.circle.fill")) - } else { - UserDefaults.standard.set(0.0, forKey: "lastPlayedTime_\(ep.href)") - UserDefaults.standard.set(0.0, forKey: "totalTime_\(ep.href)") - DropManager.shared.showDrop(title: "Progress Reset", subtitle: "", duration: 1.0, icon: UIImage(systemName: "arrow.counterclockwise")) - } - } - }) { - HStack(spacing: 4) { - Image(systemName: { - if let ep = episodeLinks.first { - let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)") - let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)") - let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0 - return progress <= 0.9 ? "checkmark.circle" : "arrow.counterclockwise" - } - return "checkmark.circle" - }()) - .foregroundColor(.primary) - Text({ - if let ep = episodeLinks.first { - let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)") - let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)") - let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0 - return progress <= 0.9 ? "Mark watched" : "Reset progress" - } - return "Mark watched" - }()) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.primary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 6) - .background(Color.gray.opacity(0.2)) - .cornerRadius(15) - .gradientOutline() - } - - Button(action: { - if let ep = episodeLinks.first { - let downloadStatus = jsController.isEpisodeDownloadedOrInProgress( - showTitle: title, - episodeNumber: ep.number, - season: 1 - ) - - if downloadStatus == .notDownloaded { - downloadSingleEpisodeDirectly(episode: ep) - DropManager.shared.showDrop(title: "Starting Download", subtitle: "", duration: 1.0, icon: UIImage(systemName: "arrow.down.circle")) - } else { - DropManager.shared.showDrop(title: "Already Downloaded", subtitle: "", duration: 1.0, icon: UIImage(systemName: "checkmark.circle")) - } - } - }) { - HStack(spacing: 4) { - Image(systemName: "arrow.down.circle") - .foregroundColor(.primary) - Text("Download") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.primary) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 6) - .background(Color.gray.opacity(0.2)) - .cornerRadius(15) - .gradientOutline() - } - - menuButton - } - Text("Why am I not seeing any episodes?") - .font(.caption) - .bold() - .foregroundColor(.gray) - .multilineTextAlignment(.leading) - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 4) - Text("The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases.") - .font(.caption) - .foregroundColor(.gray) - .multilineTextAlignment(.leading) - .lineLimit(nil) - .fixedSize(horizontal: false, vertical: true) - .padding(.top, 4) - } + singleEpisodeSection } } } @ViewBuilder - private var contentSection: some View { - VStack(alignment: .leading, spacing: 20) { - playAndBookmarkSection + private var synopsisSection: some View { + HStack(alignment: .bottom) { + Text(synopsis) + .font(.system(size: 16)) + .foregroundColor(.secondary) + .lineLimit(showFullSynopsis ? nil : 3) + .animation(nil, value: showFullSynopsis) - if !episodeLinks.isEmpty { - episodesSection - } else { - noEpisodesSection - } + Text(showFullSynopsis ? "LESS" : "MORE") + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.accentColor) + .animation(.easeInOut(duration: 0.3), value: showFullSynopsis) } - .padding(.horizontal, 20) - .padding(.vertical, 20) - .background( - Rectangle() - .fill(colorScheme == .dark ? Color.black : Color.white) - ) - } - - @ViewBuilder - private var sourceButton: some View { - Button(action: { - openSafariViewController(with: href) - }) { - Image(systemName: "safari") - .resizable() - .frame(width: 16, height: 16) - .foregroundColor(.primary) - .padding(6) - .background(Color.gray.opacity(0.2)) - .clipShape(Circle()) - .circularGradientOutline() - } - } - - @ViewBuilder - private var menuButton: some View { - Menu { - if let id = itemID ?? customAniListID { - let labelText = (matchedTitle?.isEmpty == false ? matchedTitle! : "\(id)") - Text("Matched with: \(labelText)") - .font(.caption) - .foregroundColor(.gray) - .padding(.vertical, 4) - } - - Divider() - - if let _ = customAniListID { - Button(action: { - customAniListID = nil - itemID = nil - matchedTitle = nil - fetchItemID(byTitle: cleanTitle(title)) { result in - switch result { - case .success(let id): - itemID = id - case .failure(let error): - Logger.shared.log("Failed to fetch AniList ID: \(error)") - } - } - }) { - Label("Reset AniList ID", systemImage: "arrow.clockwise") - } - } - - if let id = itemID ?? customAniListID { - Button(action: { - if let url = URL(string: "https://anilist.co/anime/\(id)") { - openSafariViewController(with: url.absoluteString) - } - }) { - Label("Open in AniList", systemImage: "link") - } - } - - if UserDefaults.standard.string(forKey: "metadataProviders") ?? "TMDB" == "AniList" { - Button(action: { - isMatchingPresented = true - }) { - Label("Match with AniList", systemImage: "magnifyingglass") - } - } - - if UserDefaults.standard.string(forKey: "originalPoster_\(href)") != nil { - Button(action: { - if let originalPoster = UserDefaults.standard.string(forKey: "originalPoster_\(href)") { - imageUrl = originalPoster - UserDefaults.standard.removeObject(forKey: "tmdbPosterURL_\(href)") - UserDefaults.standard.removeObject(forKey: "originalPoster_\(href)") - } - }) { - Label("Original Poster", systemImage: "photo.badge.arrow.down") - } - } else { - Button(action: { - fetchTMDBPosterImageAndSet() - }) { - Label("Use TMDB Poster Image", systemImage: "photo") - } - } - - Divider() - - Button(action: { - Logger.shared.log(""" - Debug Info: - Title: \(title) - Href: \(href) - Module: \(module.metadata.sourceName) - AniList ID: \(itemID ?? -1) - Custom ID: \(customAniListID ?? -1) - Matched Title: \(matchedTitle ?? "—") - """, type: "Debug") - DropManager.shared.showDrop( - title: "Debug Info Logged", - subtitle: "", - duration: 1.0, - icon: UIImage(systemName: "terminal") - ) - }) { - Label("Log Debug Info", systemImage: "terminal") - } - } label: { - Image(systemName: "ellipsis") - .resizable() - .frame(width: 16, height: 4) - .foregroundColor(.primary) - .padding(12) - .background(Color.gray.opacity(0.2)) - .clipShape(Circle()) - .circularGradientOutline() - } - .sheet(isPresented: $isMatchingPresented) { - AnilistMatchPopupView(seriesTitle: title) { selectedID in - self.customAniListID = selectedID - self.itemID = selectedID - UserDefaults.standard.set(selectedID, forKey: "custom_anilist_id_\(href)") - self.fetchDetails() - isMatchingPresented = false + .onTapGesture { + withAnimation(.easeInOut(duration: 0.3)) { + showFullSynopsis.toggle() } } } @@ -567,9 +353,8 @@ struct MediaInfoView: View { @ViewBuilder private var playAndBookmarkSection: some View { HStack(spacing: 12) { - Button(action: { - playFirstUnwatchedEpisode() - }) { + // Play/Continue button + Button(action: { playFirstUnwatchedEpisode() }) { HStack(spacing: 8) { Image(systemName: "play.fill") .foregroundColor(colorScheme == .dark ? .black : .white) @@ -587,16 +372,9 @@ struct MediaInfoView: View { } .disabled(isFetchingEpisode) - Button(action: { - libraryManager.toggleBookmark( - title: title, - imageUrl: imageUrl, - href: href, - moduleId: module.id.uuidString, - moduleName: module.metadata.sourceName - ) - }) { - Image(systemName: libraryManager.isBookmarked(href: href, moduleName: module.metadata.sourceName) ? "bookmark.fill" : "bookmark") + // Bookmark button + Button(action: { toggleBookmark() }) { + Image(systemName: isBookmarked ? "bookmark.fill" : "bookmark") .resizable() .frame(width: 16, height: 22) .foregroundColor(.primary) @@ -609,69 +387,161 @@ struct MediaInfoView: View { } @ViewBuilder - private var episodesSection: some View { - if episodeLinks.count == 1 { - EmptyView() - } else { - VStack(alignment: .leading, spacing: 16) { - HStack { - Text("Episodes") - .font(.system(size: 22, weight: .bold)) - .foregroundColor(.primary) - - Spacer() - - Group { - if !isGroupedBySeasons && episodeLinks.count <= episodeChunkSize { - Text("") - .font(.system(size: 14)) - .foregroundColor(.secondary) - } else { - episodeNavigationSection - } - } - + private var singleEpisodeSection: some View { + VStack(spacing: 12) { + HStack(spacing: 12) { + // Mark watched button + Button(action: { toggleSingleEpisodeWatchStatus() }) { HStack(spacing: 4) { - sourceButton - menuButton + Image(systemName: singleEpisodeWatchIcon) + .foregroundColor(.primary) + Text(singleEpisodeWatchText) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primary) } + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .background(Color.gray.opacity(0.2)) + .cornerRadius(15) + .gradientOutline() } + // Download button + Button(action: { downloadSingleEpisode() }) { + HStack(spacing: 4) { + Image(systemName: "arrow.down.circle") + .foregroundColor(.primary) + Text("Download") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .background(Color.gray.opacity(0.2)) + .cornerRadius(15) + .gradientOutline() + } + + menuButton + } + + // Information text for single episodes + VStack(spacing: 4) { + Text("Why am I not seeing any episodes?") + .font(.caption) + .bold() + .foregroundColor(.gray) + .frame(maxWidth: .infinity, alignment: .leading) + + Text("The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases.") + .font(.caption) + .foregroundColor(.gray) + .multilineTextAlignment(.leading) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.top, 4) + } + } + + // MARK: - Computed Properties for Single Episode + + private var isBookmarked: Bool { + libraryManager.isBookmarked(href: href, moduleName: module.metadata.sourceName) + } + + private var singleEpisodeWatchIcon: String { + if let ep = episodeLinks.first { + let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)") + let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)") + let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0 + return progress <= 0.9 ? "checkmark.circle" : "arrow.counterclockwise" + } + return "checkmark.circle" + } + + private var singleEpisodeWatchText: String { + if let ep = episodeLinks.first { + let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)") + let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)") + let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0 + return progress <= 0.9 ? "Mark watched" : "Reset progress" + } + return "Mark watched" + } + + // MARK: - Episodes Section + + @ViewBuilder + private var episodesSection: some View { + if episodeLinks.count != 1 { + VStack(alignment: .leading, spacing: 16) { + episodesSectionHeader episodeListSection } } } + @ViewBuilder + private var episodesSectionHeader: some View { + HStack { + Text("Episodes") + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.primary) + + Spacer() + + episodeNavigationSection + + HStack(spacing: 4) { + sourceButton + menuButton + } + } + } + @ViewBuilder private var episodeNavigationSection: some View { Group { - if !isGroupedBySeasons, episodeLinks.count > episodeChunkSize { - Menu { - ForEach(generateRanges(), id: \.self) { range in - Button(action: { selectedRange = range }) { - Text("\(range.lowerBound + 1)-\(range.upperBound)") - } - } - } label: { - Text("\(selectedRange.lowerBound + 1)-\(selectedRange.upperBound)") - .font(.system(size: 14)) - .foregroundColor(.accentColor) - } + if !isGroupedBySeasons && episodeLinks.count <= episodeChunkSize { + EmptyView() + } else if !isGroupedBySeasons && episodeLinks.count > episodeChunkSize { + rangeSelectionMenu } else if isGroupedBySeasons { - let seasons = groupedEpisodes() - if seasons.count > 1 { - Menu { - ForEach(0.. 1 { + Menu { + ForEach(0.. 0 ? lastPlayedTime / totalTime : 0 - - let defaultBannerImageValue = getBannerImageBasedOnAppearance() - - EpisodeCell( - episodeIndex: i, - episode: ep.href, - episodeID: ep.number - 1, - progress: progress, - itemID: itemID ?? 0, - totalEpisodes: episodeLinks.count, - defaultBannerImage: defaultBannerImageValue, - module: module, - parentTitle: title, - showPosterURL: imageUrl, - isMultiSelectMode: isMultiSelectMode, - isSelected: selectedEpisodes.contains(ep.number), - onSelectionChanged: { isSelected in - if isSelected { - selectedEpisodes.insert(ep.number) - } else { - selectedEpisodes.remove(ep.number) - } - }, - onTap: { imageUrl in - episodeTapAction(ep: ep, imageUrl: imageUrl) - }, - onMarkAllPrevious: { - markAllPreviousEpisodesInFlatList(ep: ep, index: i) - }, - tmdbID: tmdbID, - seasonNumber: 1 - ) - .disabled(isFetchingEpisode) + createEpisodeCell(episode: ep, index: i, season: 1) } } } @@ -738,42 +573,7 @@ struct MediaInfoView: View { if !seasons.isEmpty, selectedSeason < seasons.count { VStack(spacing: 15) { ForEach(seasons[selectedSeason]) { ep in - let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)") - let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)") - let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0 - - let defaultBannerImageValue = getBannerImageBasedOnAppearance() - - EpisodeCell( - episodeIndex: selectedSeason, - episode: ep.href, - episodeID: ep.number - 1, - progress: progress, - itemID: itemID ?? 0, - totalEpisodes: episodeLinks.count, - defaultBannerImage: defaultBannerImageValue, - module: module, - parentTitle: title, - showPosterURL: imageUrl, - isMultiSelectMode: isMultiSelectMode, - isSelected: selectedEpisodes.contains(ep.number), - onSelectionChanged: { isSelected in - if isSelected { - selectedEpisodes.insert(ep.number) - } else { - selectedEpisodes.remove(ep.number) - } - }, - onTap: { imageUrl in - episodeTapAction(ep: ep, imageUrl: imageUrl) - }, - onMarkAllPrevious: { - markAllPreviousEpisodesAsWatched(ep: ep, inSeason: true) - }, - tmdbID: tmdbID, - seasonNumber: selectedSeason + 1 - ) - .disabled(isFetchingEpisode) + createEpisodeCell(episode: ep, index: selectedSeason, season: selectedSeason + 1) } } } else { @@ -781,6 +581,385 @@ struct MediaInfoView: View { } } + @ViewBuilder + private func createEpisodeCell(episode: EpisodeLink, index: Int, season: Int) -> some View { + let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(episode.href)") + let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(episode.href)") + let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0 + let defaultBannerImageValue = getBannerImageBasedOnAppearance() + + EpisodeCell( + episodeIndex: index, + episode: episode.href, + episodeID: episode.number - 1, + progress: progress, + itemID: itemID ?? 0, + totalEpisodes: episodeLinks.count, + defaultBannerImage: defaultBannerImageValue, + module: module, + parentTitle: title, + showPosterURL: imageUrl, + isMultiSelectMode: isMultiSelectMode, + isSelected: selectedEpisodes.contains(episode.number), + onSelectionChanged: { isSelected in + handleEpisodeSelection(episode: episode, isSelected: isSelected) + }, + onTap: { imageUrl in + episodeTapAction(ep: episode, imageUrl: imageUrl) + }, + onMarkAllPrevious: { + markAllPreviousEpisodes(episode: episode, index: index, inSeason: isGroupedBySeasons) + }, + tmdbID: tmdbID, + seasonNumber: season + ) + .disabled(isFetchingEpisode) + } + + @ViewBuilder + private var noEpisodesSection: some View { + VStack(spacing: 8) { + Image(systemName: "tv.slash") + .font(.system(size: 48)) + .foregroundColor(.secondary) + + Text("No Episodes Available") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.primary) + + Text("Episodes might not be available yet or there could be an issue with the source.") + .font(.body) + .lineLimit(0) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + .padding(.vertical, 50) + } + + // MARK: - Menu and Action Buttons + + @ViewBuilder + private var sourceButton: some View { + Button(action: { openSafariViewController(with: href) }) { + Image(systemName: "safari") + .resizable() + .frame(width: 16, height: 16) + .foregroundColor(.primary) + .padding(6) + .background(Color.gray.opacity(0.2)) + .clipShape(Circle()) + .circularGradientOutline() + } + } + + @ViewBuilder + private var menuButton: some View { + Menu { + menuContent + } label: { + Image(systemName: "ellipsis") + .resizable() + .frame(width: 16, height: 4) + .foregroundColor(.primary) + .padding(12) + .background(Color.gray.opacity(0.2)) + .clipShape(Circle()) + .circularGradientOutline() + } + .sheet(isPresented: $isMatchingPresented) { + AnilistMatchPopupView(seriesTitle: title) { selectedID in + handleAniListMatch(selectedID: selectedID) + } + } + } + + @ViewBuilder + private var menuContent: some View { + Group { + // Current match info + if let id = itemID ?? customAniListID { + let labelText = (matchedTitle?.isEmpty == false ? matchedTitle! : "\(id)") + Text("Matched with: \(labelText)") + .font(.caption) + .foregroundColor(.gray) + .padding(.vertical, 4) + } + + Divider() + + // Reset AniList ID + if let _ = customAniListID { + Button(action: { resetAniListID() }) { + Label("Reset AniList ID", systemImage: "arrow.clockwise") + } + } + + // Open in AniList + if let id = itemID ?? customAniListID { + Button(action: { openAniListPage(id: id) }) { + Label("Open in AniList", systemImage: "link") + } + } + + // Match with AniList + if UserDefaults.standard.string(forKey: "metadataProviders") ?? "TMDB" == "AniList" { + Button(action: { isMatchingPresented = true }) { + Label("Match with AniList", systemImage: "magnifyingglass") + } + } + + // Poster options + posterMenuOptions + + Divider() + + // Debug info + Button(action: { logDebugInfo() }) { + Label("Log Debug Info", systemImage: "terminal") + } + } + } + + @ViewBuilder + private var posterMenuOptions: some View { + Group { + if UserDefaults.standard.string(forKey: "originalPoster_\(href)") != nil { + Button(action: { restoreOriginalPoster() }) { + Label("Original Poster", systemImage: "photo.badge.arrow.down") + } + } else { + Button(action: { fetchTMDBPosterImageAndSet() }) { + Label("Use TMDB Poster Image", systemImage: "photo") + } + } + } + } + + // MARK: - Setup and Lifecycle Methods + + private func setupViewOnAppear() { + buttonRefreshTrigger.toggle() + tabBarController.hideTabBar() + + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let navigationController = window.rootViewController?.children.first as? UINavigationController { + navigationController.interactivePopGestureRecognizer?.isEnabled = true + navigationController.interactivePopGestureRecognizer?.delegate = nil + } + } + + private func setupInitialData() async { + guard !hasFetched else { return } + + let savedCustomID = UserDefaults.standard.integer(forKey: "custom_anilist_id_\(href)") + if savedCustomID != 0 { customAniListID = savedCustomID } + + if let savedPoster = UserDefaults.standard.string(forKey: "tmdbPosterURL_\(href)") { + imageUrl = savedPoster + } + + DropManager.shared.showDrop( + title: "Fetching Data", + subtitle: "Please wait while fetching.", + duration: 0.5, + icon: UIImage(systemName: "arrow.triangle.2.circlepath") + ) + + fetchDetails() + + if savedCustomID != 0 { + itemID = savedCustomID + } else { + fetchMetadataIDIfNeeded() + } + + hasFetched = true + AnalyticsManager.shared.sendEvent( + event: "MediaInfoView", + additionalData: ["title": title] + ) + } + + private func cancelCurrentFetch() { + activeFetchID = nil + isFetchingEpisode = false + showStreamLoadingView = false + showLoadingAlert = false + } + + // MARK: - Action Methods + + private func copyTitleToClipboard() { + UIPasteboard.general.string = title + DropManager.shared.showDrop( + title: "Copied to Clipboard", + subtitle: "", + duration: 1.0, + icon: UIImage(systemName: "doc.on.clipboard.fill") + ) + } + + private func toggleBookmark() { + libraryManager.toggleBookmark( + title: title, + imageUrl: imageUrl, + href: href, + moduleId: module.id.uuidString, + moduleName: module.metadata.sourceName + ) + } + + private func toggleSingleEpisodeWatchStatus() { + if let ep = episodeLinks.first { + let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)") + let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)") + let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0 + + if progress <= 0.9 { + UserDefaults.standard.set(99999999.0, forKey: "lastPlayedTime_\(ep.href)") + UserDefaults.standard.set(99999999.0, forKey: "totalTime_\(ep.href)") + DropManager.shared.showDrop( + title: "Marked as Watched", + subtitle: "", + duration: 1.0, + icon: UIImage(systemName: "checkmark.circle.fill") + ) + } else { + UserDefaults.standard.set(0.0, forKey: "lastPlayedTime_\(ep.href)") + UserDefaults.standard.set(0.0, forKey: "totalTime_\(ep.href)") + DropManager.shared.showDrop( + title: "Progress Reset", + subtitle: "", + duration: 1.0, + icon: UIImage(systemName: "arrow.counterclockwise") + ) + } + } + } + + private func downloadSingleEpisode() { + if let ep = episodeLinks.first { + let downloadStatus = jsController.isEpisodeDownloadedOrInProgress( + showTitle: title, + episodeNumber: ep.number, + season: 1 + ) + + if downloadStatus == .notDownloaded { + downloadSingleEpisodeDirectly(episode: ep) + DropManager.shared.showDrop( + title: "Starting Download", + subtitle: "", + duration: 1.0, + icon: UIImage(systemName: "arrow.down.circle") + ) + } else { + DropManager.shared.showDrop( + title: "Already Downloaded", + subtitle: "", + duration: 1.0, + icon: UIImage(systemName: "checkmark.circle") + ) + } + } + } + + private func handleEpisodeSelection(episode: EpisodeLink, isSelected: Bool) { + if isSelected { + selectedEpisodes.insert(episode.number) + } else { + selectedEpisodes.remove(episode.number) + } + } + + private func episodeTapAction(ep: EpisodeLink, imageUrl: String) { + if !isFetchingEpisode { + selectedEpisodeNumber = ep.number + selectedEpisodeImage = imageUrl + fetchStream(href: ep.href) + AnalyticsManager.shared.sendEvent( + event: "watch", + additionalData: ["title": title, "episode": ep.number] + ) + } + } + + private func markAllPreviousEpisodes(episode: EpisodeLink, index: Int, inSeason: Bool) { + if inSeason { + markAllPreviousEpisodesAsWatched(ep: episode, inSeason: true) + } else { + markAllPreviousEpisodesInFlatList(ep: episode, index: index) + } + } + + // MARK: - Menu Action Methods + + private func handleAniListMatch(selectedID: Int) { + self.customAniListID = selectedID + self.itemID = selectedID + UserDefaults.standard.set(selectedID, forKey: "custom_anilist_id_\(href)") + self.fetchDetails() + isMatchingPresented = false + } + + private func resetAniListID() { + customAniListID = nil + itemID = nil + matchedTitle = nil + fetchItemID(byTitle: cleanTitle(title)) { result in + switch result { + case .success(let id): + itemID = id + case .failure(let error): + Logger.shared.log("Failed to fetch AniList ID: \(error)") + } + } + } + + private func openAniListPage(id: Int) { + if let url = URL(string: "https://anilist.co/anime/\(id)") { + openSafariViewController(with: url.absoluteString) + } + } + + private func restoreOriginalPoster() { + if let originalPoster = UserDefaults.standard.string(forKey: "originalPoster_\(href)") { + imageUrl = originalPoster + UserDefaults.standard.removeObject(forKey: "tmdbPosterURL_\(href)") + UserDefaults.standard.removeObject(forKey: "originalPoster_\(href)") + } + } + + private func logDebugInfo() { + Logger.shared.log(""" + Debug Info: + Title: \(title) + Href: \(href) + Module: \(module.metadata.sourceName) + AniList ID: \(itemID ?? -1) + Custom ID: \(customAniListID ?? -1) + Matched Title: \(matchedTitle ?? "—") + """, type: "Debug") + DropManager.shared.showDrop( + title: "Debug Info Logged", + subtitle: "", + duration: 1.0, + icon: UIImage(systemName: "terminal") + ) + } + + // MARK: - Utility Methods + + private func getBannerImageBasedOnAppearance() -> 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" + } + private func restoreSelectionState() { if let savedStart = UserDefaults.standard.object(forKey: selectedRangeKey) as? Int, let savedRange = generateRanges().first(where: { $0.lowerBound == savedStart }) { @@ -795,87 +974,118 @@ struct MediaInfoView: View { } } - private func getBannerImageBasedOnAppearance() -> 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" + private func generateRanges() -> [Range] { + let chunkSize = episodeChunkSize + let totalEpisodes = episodeLinks.count + var ranges: [Range] = [] + + for i in stride(from: 0, to: totalEpisodes, by: chunkSize) { + let end = min(i + chunkSize, totalEpisodes) + ranges.append(i.. [[EpisodeLink]] { + guard !episodeLinks.isEmpty else { return [] } + var groups: [[EpisodeLink]] = [] + var currentGroup: [EpisodeLink] = [episodeLinks[0]] + + for ep in episodeLinks.dropFirst() { + if let last = currentGroup.last, ep.number < last.number { + groups.append(currentGroup) + currentGroup = [ep] + } else { + currentGroup.append(ep) + } + } + + groups.append(currentGroup) + return groups + } + + private func cleanTitle(_ title: String?) -> String { + guard let title = title else { return "Unknown" } + + let cleaned = title.replacingOccurrences( + of: "\\s*\\([^\\)]*\\)", + with: "", + options: .regularExpression + ).trimmingCharacters(in: .whitespaces) + + return cleaned.isEmpty ? "Unknown" : cleaned + } + + // MARK: - Playback Methods + + private func playFirstUnwatchedEpisode() { + let indices = finishedAndUnfinishedIndices() + let finished = indices.finished + let unfinished = indices.unfinished + + if let finishedIndex = finished, finishedIndex < episodeLinks.count - 1 { + let nextEp = episodeLinks[finishedIndex + 1] + selectedEpisodeNumber = nextEp.number + fetchStream(href: nextEp.href) + return + } + + if let unfinishedIndex = unfinished { + let ep = episodeLinks[unfinishedIndex] selectedEpisodeNumber = ep.number - selectedEpisodeImage = imageUrl fetchStream(href: ep.href) - AnalyticsManager.shared.sendEvent( - event: "watch", - additionalData: ["title": title, "episode": ep.number] - ) + return + } + + if let firstEpisode = episodeLinks.first { + selectedEpisodeNumber = firstEpisode.number + fetchStream(href: firstEpisode.href) } } - private func fetchMetadataIDIfNeeded() { - let provider = UserDefaults.standard.string(forKey: "metadataProviders") ?? "TMDB" - let cleaned = cleanTitle(title) + private func finishedAndUnfinishedIndices() -> (finished: Int?, unfinished: Int?) { + var finishedIndex: Int? = nil + var firstUnfinishedIndex: Int? = nil - if provider == "TMDB" { - tmdbID = nil - tmdbFetcher.fetchBestMatchID(for: cleaned) { id, type in - DispatchQueue.main.async { - self.tmdbID = id - self.tmdbType = type - Logger.shared.log("Fetched TMDB ID: \(id ?? -1) (\(type?.rawValue ?? "unknown")) for title: \(cleaned)", type: "Debug") - } - } - } else if provider == "Anilist" { - itemID = nil - fetchItemID(byTitle: cleaned) { result in - switch result { - case .success(let id): - DispatchQueue.main.async { - self.itemID = id - Logger.shared.log("Fetched AniList ID: \(id) for title: \(cleaned)", type: "Debug") - } - case .failure(let error): - Logger.shared.log("Failed to fetch AniList ID: \(error)", type: "Error") - } + for (index, ep) in episodeLinks.enumerated() { + let keyLast = "lastPlayedTime_\(ep.href)" + let keyTotal = "totalTime_\(ep.href)" + let lastPlayedTime = UserDefaults.standard.double(forKey: keyLast) + let totalTime = UserDefaults.standard.double(forKey: keyTotal) + + guard totalTime > 0 else { continue } + + let remainingFraction = (totalTime - lastPlayedTime) / totalTime + if remainingFraction <= 0.1 { + finishedIndex = index + } else if firstUnfinishedIndex == nil { + firstUnfinishedIndex = index } } + return (finishedIndex, firstUnfinishedIndex) } - private func fetchTMDBPosterImageAndSet() { - guard let tmdbID = tmdbID, let tmdbType = tmdbType else { return } - let apiType = tmdbType.rawValue - let urlString = "https://api.themoviedb.org/3/\(apiType)/\(tmdbID)?api_key=738b4edd0a156cc126dc4a4b8aea4aca" - guard let url = URL(string: urlString) else { return } + private func selectNextEpisode() { + guard let currentIndex = episodeLinks.firstIndex(where: { $0.number == selectedEpisodeNumber }), + currentIndex + 1 < episodeLinks.count else { + Logger.shared.log("No more episodes to play", type: "Info") + return + } - let tmdbImageWidth = UserDefaults.standard.string(forKey: "tmdbImageWidth") ?? "original" - - URLSession.custom.dataTask(with: url) { data, _, error in - guard let data = data, error == nil else { return } - do { - if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let posterPath = json["poster_path"] as? String { - let imageUrl: String - if tmdbImageWidth == "original" { - imageUrl = "https://image.tmdb.org/t/p/original\(posterPath)" - } else { - imageUrl = "https://image.tmdb.org/t/p/w\(tmdbImageWidth)\(posterPath)" - } - DispatchQueue.main.async { - let currentPosterKey = "originalPoster_\(self.href)" - let currentPoster = self.imageUrl - UserDefaults.standard.set(currentPoster, forKey: currentPosterKey) - self.imageUrl = imageUrl - UserDefaults.standard.set(imageUrl, forKey: "tmdbPosterURL_\(self.href)") - } - } - } catch { - Logger.shared.log("Failed to parse TMDB poster: \(error.localizedDescription)", type: "Error") - } - }.resume() + let nextEpisode = episodeLinks[currentIndex + 1] + selectedEpisodeNumber = nextEpisode.number + fetchStream(href: nextEpisode.href) + DropManager.shared.showDrop( + title: "Fetching Next Episode", + subtitle: "", + duration: 0.5, + icon: UIImage(systemName: "arrow.triangle.2.circlepath") + ) } + // MARK: - Episode Progress Management + private func markAllPreviousEpisodesAsWatched(ep: EpisodeLink, inSeason: Bool) { let userDefaults = UserDefaults.standard var updates = [String: Double]() @@ -940,130 +1150,19 @@ struct MediaInfoView: View { } } - @ViewBuilder - private var noEpisodesSection: some View { - VStack(spacing: 8) { - Image(systemName: "tv.slash") - .font(.system(size: 48)) - .foregroundColor(.secondary) - - Text("No Episodes Available") - .font(.title2) - .fontWeight(.semibold) - .foregroundColor(.primary) - - Text("Episodes might not be available yet or there could be an issue with the source.") - .font(.body) - .lineLimit(0) - .foregroundColor(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - .padding(.vertical, 50) - } - private var startWatchingText: String { - let indices = finishedAndUnfinishedIndices() - let finished = indices.finished - let unfinished = indices.unfinished - - if episodeLinks.count == 1 { - if let unfinishedIndex = unfinished { - return "Continue Watching" - } - return "Start Watching" - } - - if let finishedIndex = finished, finishedIndex < episodeLinks.count - 1 { - let nextEp = episodeLinks[finishedIndex + 1] - return "Start Watching Episode \(nextEp.number)" - } - - if let unfinishedIndex = unfinished { - let currentEp = episodeLinks[unfinishedIndex] - return "Continue Watching Episode \(currentEp.number)" - } - - return "Start Watching" - } - - private func playFirstUnwatchedEpisode() { - let indices = finishedAndUnfinishedIndices() - let finished = indices.finished - let unfinished = indices.unfinished - - if let finishedIndex = finished, finishedIndex < episodeLinks.count - 1 { - let nextEp = episodeLinks[finishedIndex + 1] - selectedEpisodeNumber = nextEp.number - fetchStream(href: nextEp.href) + private func openSafariViewController(with urlString: String) { + guard let url = URL(string: urlString), UIApplication.shared.canOpenURL(url) else { + Logger.shared.log("Unable to open the webpage", type: "Error") return } - - if let unfinishedIndex = unfinished { - let ep = episodeLinks[unfinishedIndex] - selectedEpisodeNumber = ep.number - fetchStream(href: ep.href) - return - } - - if let firstEpisode = episodeLinks.first { - selectedEpisodeNumber = firstEpisode.number - fetchStream(href: firstEpisode.href) + let safariViewController = SFSafariViewController(url: url) + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootVC = windowScene.windows.first?.rootViewController { + rootVC.present(safariViewController, animated: true, completion: nil) } } - private func finishedAndUnfinishedIndices() -> (finished: Int?, unfinished: Int?) { - var finishedIndex: Int? = nil - var firstUnfinishedIndex: Int? = nil - - for (index, ep) in episodeLinks.enumerated() { - let keyLast = "lastPlayedTime_\(ep.href)" - let keyTotal = "totalTime_\(ep.href)" - let lastPlayedTime = UserDefaults.standard.double(forKey: keyLast) - let totalTime = UserDefaults.standard.double(forKey: keyTotal) - - guard totalTime > 0 else { continue } - - let remainingFraction = (totalTime - lastPlayedTime) / totalTime - if remainingFraction <= 0.1 { - finishedIndex = index - } else if firstUnfinishedIndex == nil { - firstUnfinishedIndex = index - } - } - return (finishedIndex, firstUnfinishedIndex) - } - - private func generateRanges() -> [Range] { - let chunkSize = episodeChunkSize - let totalEpisodes = episodeLinks.count - var ranges: [Range] = [] - - for i in stride(from: 0, to: totalEpisodes, by: chunkSize) { - let end = min(i + chunkSize, totalEpisodes) - ranges.append(i.. [[EpisodeLink]] { - guard !episodeLinks.isEmpty else { return [] } - var groups: [[EpisodeLink]] = [] - var currentGroup: [EpisodeLink] = [episodeLinks[0]] - - for ep in episodeLinks.dropFirst() { - if let last = currentGroup.last, ep.number < last.number { - groups.append(currentGroup) - currentGroup = [ep] - } else { - currentGroup.append(ep) - } - } - - groups.append(currentGroup) - return groups - } func fetchDetails() { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -1105,6 +1204,117 @@ struct MediaInfoView: View { } } + private func fetchMetadataIDIfNeeded() { + let provider = UserDefaults.standard.string(forKey: "metadataProviders") ?? "TMDB" + let cleaned = cleanTitle(title) + + if provider == "TMDB" { + tmdbID = nil + tmdbFetcher.fetchBestMatchID(for: cleaned) { id, type in + DispatchQueue.main.async { + self.tmdbID = id + self.tmdbType = type + Logger.shared.log("Fetched TMDB ID: \(id ?? -1) (\(type?.rawValue ?? "unknown")) for title: \(cleaned)", type: "Debug") + } + } + } else if provider == "Anilist" { + itemID = nil + fetchItemID(byTitle: cleaned) { result in + switch result { + case .success(let id): + DispatchQueue.main.async { + self.itemID = id + Logger.shared.log("Fetched AniList ID: \(id) for title: \(cleaned)", type: "Debug") + } + case .failure(let error): + Logger.shared.log("Failed to fetch AniList ID: \(error)", type: "Error") + } + } + } + } + + private func fetchItemID(byTitle title: String, completion: @escaping (Result) -> Void) { + let query = """ + query { + Media(search: "\(title)", type: ANIME) { + id + } + } + """ + + guard let url = URL(string: "https://graphql.anilist.co") else { + completion(.failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]))) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let parameters: [String: Any] = ["query": query] + request.httpBody = try? JSONSerialization.data(withJSONObject: parameters) + + URLSession.custom.dataTask(with: request) { data, _, error in + if let error = error { + completion(.failure(error)) + return + } + + guard let data = data else { + completion(.failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "No data received"]))) + return + } + + do { + if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], + let data = json["data"] as? [String: Any], + let media = data["Media"] as? [String: Any], + let id = media["id"] as? Int { + completion(.success(id)) + } else { + let error = NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid response"]) + completion(.failure(error)) + } + } catch { + completion(.failure(error)) + } + }.resume() + } + + private func fetchTMDBPosterImageAndSet() { + guard let tmdbID = tmdbID, let tmdbType = tmdbType else { return } + let apiType = tmdbType.rawValue + let urlString = "https://api.themoviedb.org/3/\(apiType)/\(tmdbID)?api_key=738b4edd0a156cc126dc4a4b8aea4aca" + guard let url = URL(string: urlString) else { return } + + let tmdbImageWidth = UserDefaults.standard.string(forKey: "tmdbImageWidth") ?? "original" + + URLSession.custom.dataTask(with: url) { data, _, error in + guard let data = data, error == nil else { return } + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let posterPath = json["poster_path"] as? String { + let imageUrl: String + if tmdbImageWidth == "original" { + imageUrl = "https://image.tmdb.org/t/p/original\(posterPath)" + } else { + imageUrl = "https://image.tmdb.org/t/p/w\(tmdbImageWidth)\(posterPath)" + } + DispatchQueue.main.async { + let currentPosterKey = "originalPoster_\(self.href)" + let currentPoster = self.imageUrl + UserDefaults.standard.set(currentPoster, forKey: currentPosterKey) + self.imageUrl = imageUrl + UserDefaults.standard.set(imageUrl, forKey: "tmdbPosterURL_\(self.href)") + } + } + } catch { + Logger.shared.log("Failed to parse TMDB poster: \(error.localizedDescription)", type: "Error") + } + }.resume() + } + + func fetchStream(href: String) { let fetchID = UUID() activeFetchID = fetchID @@ -1113,16 +1323,12 @@ struct MediaInfoView: View { isFetchingEpisode = true let completion: ((streams: [String]?, subtitles: [String]?, sources: [[String: Any]]?)) -> Void = { result in - guard self.activeFetchID == fetchID else { - return - } + guard self.activeFetchID == fetchID else { return } self.showLoadingAlert = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - guard self.activeFetchID == fetchID else { - return - } + guard self.activeFetchID == fetchID else { return } if let sources = result.sources, !sources.isEmpty { if sources.count > 1 { @@ -1185,17 +1391,13 @@ struct MediaInfoView: View { } func showStreamSelectionAlert(sources: [Any], fullURL: String, subtitles: String? = nil, fetchID: UUID) { - guard self.activeFetchID == fetchID else { - return - } + guard self.activeFetchID == fetchID else { return } self.isFetchingEpisode = false self.showLoadingAlert = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - guard self.activeFetchID == fetchID else { - return - } + guard self.activeFetchID == fetchID else { return } let alert = UIAlertController(title: "Select Server", message: "Choose a server to play from", preferredStyle: .actionSheet) @@ -1206,6 +1408,7 @@ struct MediaInfoView: View { var title: String = "" var streamUrl: String = "" var headers: [String:String]? = nil + if let sources = sources as? [String] { if index + 1 < sources.count { if !sources[index].lowercased().contains("http") { @@ -1222,13 +1425,11 @@ struct MediaInfoView: View { streamUrl = sources[index] index += 1 } - } - else if let sources = sources as? [[String: Any]] { + } else if let sources = sources as? [[String: Any]] { if let currTitle = sources[index]["title"] as? String { title = currTitle streamUrl = (sources[index]["streamUrl"] as? String) ?? "" - } else - { + } else { title = "Stream \(streamIndex)" streamUrl = (sources[index]["streamUrl"] as? String)! } @@ -1236,11 +1437,8 @@ struct MediaInfoView: View { index += 1 } - alert.addAction(UIAlertAction(title: title, style: .default) { _ in - guard self.activeFetchID == fetchID else { - return - } + guard self.activeFetchID == fetchID else { return } self.playStream(url: streamUrl, fullURL: href, subtitles: subtitles, headers: headers, fetchID: fetchID) }) @@ -1249,44 +1447,18 @@ struct MediaInfoView: View { 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 { - - if UIDevice.current.userInterfaceIdiom == .pad { - if let popover = alert.popoverPresentationController { - popover.sourceView = window - popover.sourceRect = CGRect( - x: UIScreen.main.bounds.width / 2, - y: UIScreen.main.bounds.height / 2, - width: 0, - height: 0 - ) - popover.permittedArrowDirections = [] - } - } - - findTopViewController.findViewController(rootVC).present(alert, animated: true) - } - - DispatchQueue.main.async { - self.isFetchingEpisode = false - } + self.presentAlert(alert) } } func playStream(url: String, fullURL: String, subtitles: String? = nil, headers: [String:String]? = nil, fetchID: UUID) { - guard self.activeFetchID == fetchID else { - return - } + guard self.activeFetchID == fetchID else { return } self.isFetchingEpisode = false self.showLoadingAlert = false DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - guard self.activeFetchID == fetchID else { - return - } + guard self.activeFetchID == fetchID else { return } let externalPlayer = UserDefaults.standard.string(forKey: "externalPlayer") ?? "Sora" var scheme: String? @@ -1307,26 +1479,7 @@ struct MediaInfoView: View { case "TracyPlayer": scheme = "tracy://open?url=\(url)" case "Default": - let videoPlayerViewController = VideoPlayerViewController(module: module) - videoPlayerViewController.headers = headers - videoPlayerViewController.streamUrl = url - videoPlayerViewController.fullUrl = fullURL - videoPlayerViewController.episodeNumber = selectedEpisodeNumber - videoPlayerViewController.seasonNumber = selectedSeason + 1 - videoPlayerViewController.episodeImageUrl = selectedEpisodeImage - videoPlayerViewController.mediaTitle = title - videoPlayerViewController.subtitles = subtitles ?? "" - videoPlayerViewController.aniListID = itemID ?? 0 - videoPlayerViewController.modalPresentationStyle = .fullScreen - - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootVC = windowScene.windows.first?.rootViewController { - findTopViewController.findViewController(rootVC).present(videoPlayerViewController, animated: true, completion: nil) - } else { - Logger.shared.log("Failed to find root view controller", type: "Error") - DropManager.shared.showDrop(title: "Error", subtitle: "Failed to present player", duration: 2.0, icon: UIImage(systemName: "xmark.circle")) - } - + self.presentDefaultPlayer(url: url, fullURL: fullURL, subtitles: subtitles, headers: headers) return default: break @@ -1336,600 +1489,73 @@ struct MediaInfoView: View { UIApplication.shared.open(url, options: [:], completionHandler: nil) Logger.shared.log("Opening external app with scheme: \(url)", type: "General") } else { - guard let url = URL(string: url) else { - Logger.shared.log("Invalid stream URL: \(url)", type: "Error") - DropManager.shared.showDrop(title: "Error", subtitle: "Invalid stream URL", duration: 2.0, icon: UIImage(systemName: "xmark.circle")) - return - } - - guard self.activeFetchID == fetchID else { - return - } - - let customMediaPlayer = CustomMediaPlayerViewController( - module: module, - urlString: url.absoluteString, - fullUrl: fullURL, - title: title, - episodeNumber: selectedEpisodeNumber, - onWatchNext: { - selectNextEpisode() - }, - subtitlesURL: subtitles, - aniListID: itemID ?? 0, - totalEpisodes: episodeLinks.count, - episodeImageUrl: selectedEpisodeImage, - headers: headers ?? nil - ) - customMediaPlayer.seasonNumber = selectedSeason + 1 - customMediaPlayer.modalPresentationStyle = .fullScreen - Logger.shared.log("Opening custom media player with url: \(url)") - - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootVC = windowScene.windows.first?.rootViewController { - findTopViewController.findViewController(rootVC).present(customMediaPlayer, animated: true, completion: nil) - } else { - Logger.shared.log("Failed to find root view controller", type: "Error") - DropManager.shared.showDrop(title: "Error", subtitle: "Failed to present player", duration: 2.0, icon: UIImage(systemName: "xmark.circle")) - } + self.presentCustomPlayer(url: url, fullURL: fullURL, subtitles: subtitles, headers: headers, fetchID: fetchID) } } } - private func selectNextEpisode() { - guard let currentIndex = episodeLinks.firstIndex(where: { $0.number == selectedEpisodeNumber }), - currentIndex + 1 < episodeLinks.count else { - Logger.shared.log("No more episodes to play", type: "Info") - return - } + private func presentDefaultPlayer(url: String, fullURL: String, subtitles: String?, headers: [String:String]?) { + let videoPlayerViewController = VideoPlayerViewController(module: module) + videoPlayerViewController.headers = headers + videoPlayerViewController.streamUrl = url + videoPlayerViewController.fullUrl = fullURL + videoPlayerViewController.episodeNumber = selectedEpisodeNumber + videoPlayerViewController.seasonNumber = selectedSeason + 1 + videoPlayerViewController.episodeImageUrl = selectedEpisodeImage + videoPlayerViewController.mediaTitle = title + videoPlayerViewController.subtitles = subtitles ?? "" + videoPlayerViewController.aniListID = itemID ?? 0 + videoPlayerViewController.modalPresentationStyle = .fullScreen - let nextEpisode = episodeLinks[currentIndex + 1] - selectedEpisodeNumber = nextEpisode.number - fetchStream(href: nextEpisode.href) - DropManager.shared.showDrop(title: "Fetching Next Episode", subtitle: "", duration: 0.5, icon: UIImage(systemName: "arrow.triangle.2.circlepath")) - } - - private func openSafariViewController(with urlString: String) { - guard let url = URL(string: urlString), UIApplication.shared.canOpenURL(url) else { - Logger.shared.log("Unable to open the webpage", type: "Error") - return - } - let safariViewController = SFSafariViewController(url: url) if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, let rootVC = windowScene.windows.first?.rootViewController { - rootVC.present(safariViewController, animated: true, completion: nil) + findTopViewController.findViewController(rootVC).present(videoPlayerViewController, animated: true, completion: nil) + } else { + Logger.shared.log("Failed to find root view controller", type: "Error") + DropManager.shared.showDrop(title: "Error", subtitle: "Failed to present player", duration: 2.0, icon: UIImage(systemName: "xmark.circle")) } } - private func cleanTitle(_ title: String?) -> String { - guard let title = title else { return "Unknown" } - - let cleaned = title.replacingOccurrences( - of: "\\s*\\([^\\)]*\\)", - with: "", - options: .regularExpression - ).trimmingCharacters(in: .whitespaces) - - return cleaned.isEmpty ? "Unknown" : cleaned - } - - private func fetchItemID(byTitle title: String, completion: @escaping (Result) -> Void) { - let query = """ - query { - Media(search: "\(title)", type: ANIME) { - id - } - } - """ - - guard let url = URL(string: "https://graphql.anilist.co") else { - completion(.failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid URL"]))) + private func presentCustomPlayer(url: String, fullURL: String, subtitles: String?, headers: [String:String]?, fetchID: UUID) { + guard let url = URL(string: url) else { + Logger.shared.log("Invalid stream URL: \(url)", type: "Error") + DropManager.shared.showDrop(title: "Error", subtitle: "Invalid stream URL", duration: 2.0, icon: UIImage(systemName: "xmark.circle")) return } - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") + guard self.activeFetchID == fetchID else { return } - let parameters: [String: Any] = ["query": query] - request.httpBody = try? JSONSerialization.data(withJSONObject: parameters) - - URLSession.custom.dataTask(with: request) { data, _, error in - if let error = error { - completion(.failure(error)) - return - } - - guard let data = data else { - completion(.failure(NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "No data received"]))) - return - } - - do { - if let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any], - let data = json["data"] as? [String: Any], - let media = data["Media"] as? [String: Any], - let id = media["id"] as? Int { - completion(.success(id)) - } else { - let error = NSError(domain: "", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid response"]) - completion(.failure(error)) - } - } catch { - completion(.failure(error)) - } - }.resume() - } - - private func showCustomIDAlert() { - let alert = UIAlertController(title: "Set Custom AniList ID", message: "Enter the AniList ID for this media", preferredStyle: .alert) - - alert.addTextField { textField in - textField.placeholder = "AniList ID" - textField.keyboardType = .numberPad - if let customID = customAniListID { - textField.text = "\(customID)" - } - } - - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) - alert.addAction(UIAlertAction(title: "Save", style: .default) { _ in - if let text = alert.textFields?.first?.text, - let id = Int(text) { - customAniListID = id - itemID = id - UserDefaults.standard.set(id, forKey: "custom_anilist_id_\(href)") - Logger.shared.log("Set custom AniList ID: \(id)", type: "General") - self.fetchDetails() - } - }) + let customMediaPlayer = CustomMediaPlayerViewController( + module: module, + urlString: url.absoluteString, + fullUrl: fullURL, + title: title, + episodeNumber: selectedEpisodeNumber, + onWatchNext: { selectNextEpisode() }, + subtitlesURL: subtitles, + aniListID: itemID ?? 0, + totalEpisodes: episodeLinks.count, + episodeImageUrl: selectedEpisodeImage, + headers: headers ?? nil + ) + customMediaPlayer.seasonNumber = selectedSeason + 1 + customMediaPlayer.modalPresentationStyle = .fullScreen + Logger.shared.log("Opening custom media player with url: \(url)") 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) - } - } - - private func selectEpisodeRange(start: Int, end: Int) { - selectedEpisodes.removeAll() - for episodeNumber in start...end { - selectedEpisodes.insert(episodeNumber) - } - showRangeInput = false - } - - private func selectAllVisibleEpisodes() { - if isGroupedBySeasons { - let seasons = groupedEpisodes() - if !seasons.isEmpty, selectedSeason < seasons.count { - for episode in seasons[selectedSeason] { - selectedEpisodes.insert(episode.number) - } - } + let rootVC = windowScene.windows.first?.rootViewController { + findTopViewController.findViewController(rootVC).present(customMediaPlayer, animated: true, completion: nil) } else { - for i in episodeLinks.indices.filter({ selectedRange.contains($0) }) { - selectedEpisodes.insert(episodeLinks[i].number) - } + Logger.shared.log("Failed to find root view controller", type: "Error") + DropManager.shared.showDrop(title: "Error", subtitle: "Failed to present player", duration: 2.0, icon: UIImage(systemName: "xmark.circle")) } } - private func startBulkDownload() { - guard !selectedEpisodes.isEmpty else { return } - - isBulkDownloading = true - bulkDownloadProgress = "Starting downloads..." - let episodesToDownload = episodeLinks.filter { selectedEpisodes.contains($0.number) } - - Task { - await processBulkDownload(episodes: episodesToDownload) - } - } - - @MainActor - private func processBulkDownload(episodes: [EpisodeLink]) async { - let totalCount = episodes.count - var completedCount = 0 - var successCount = 0 - - for (index, episode) in episodes.enumerated() { - bulkDownloadProgress = "Downloading episode \(episode.number) (\(index + 1)/\(totalCount))" - - let downloadStatus = jsController.isEpisodeDownloadedOrInProgress( - showTitle: title, - episodeNumber: episode.number, - season: 1 - ) - - switch downloadStatus { - case .downloaded: - Logger.shared.log("Episode \(episode.number) already downloaded, skipping", type: "Info") - case .downloading: - Logger.shared.log("Episode \(episode.number) already downloading, skipping", type: "Info") - case .notDownloaded: - let downloadSuccess = await downloadSingleEpisode(episode: episode) - if downloadSuccess { - successCount += 1 - } - } - - completedCount += 1 - - try? await Task.sleep(nanoseconds: 500_000_000) - } - - isBulkDownloading = false - bulkDownloadProgress = "" - isMultiSelectMode = false - selectedEpisodes.removeAll() - - DropManager.shared.showDrop( - title: "Bulk Download Complete", - subtitle: "\(successCount)/\(totalCount) episodes queued for download", - duration: 2.0, - icon: UIImage(systemName: successCount == totalCount ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") - ) - } - - private func downloadSingleEpisode(episode: EpisodeLink) async -> Bool { - return await withCheckedContinuation { continuation in - Task { - do { - let jsContent = try moduleManager.getModuleContent(module) - jsController.loadScript(jsContent) - - self.tryNextDownloadMethodForBulk( - episode: episode, - methodIndex: 0, - softsub: module.metadata.softsub == true, - continuation: continuation - ) - } catch { - Logger.shared.log("Error downloading episode \(episode.number): \(error)", type: "Error") - continuation.resume(returning: false) - } - } - } - } - - private func tryNextDownloadMethodForBulk( - episode: EpisodeLink, - methodIndex: Int, - softsub: Bool, - continuation: CheckedContinuation - ) { - print("[Bulk Download] Trying download method #\(methodIndex+1) for Episode \(episode.number)") - - switch methodIndex { - case 0: - if module.metadata.asyncJS == true { - jsController.fetchStreamUrlJS(episodeUrl: episode.href, softsub: softsub, module: module) { result in - self.handleBulkDownloadResult(result, episode: episode, methodIndex: methodIndex, softsub: softsub, continuation: continuation) - } - } else { - tryNextDownloadMethodForBulk(episode: episode, methodIndex: methodIndex + 1, softsub: softsub, continuation: continuation) - } - - case 1: - if module.metadata.streamAsyncJS == true { - jsController.fetchStreamUrlJSSecond(episodeUrl: episode.href, softsub: softsub, module: module) { result in - self.handleBulkDownloadResult(result, episode: episode, methodIndex: methodIndex, softsub: softsub, continuation: continuation) - } - } else { - tryNextDownloadMethodForBulk(episode: episode, methodIndex: methodIndex + 1, softsub: softsub, continuation: continuation) - } - - case 2: - jsController.fetchStreamUrl(episodeUrl: episode.href, softsub: softsub, module: module) { result in - self.handleBulkDownloadResult(result, episode: episode, methodIndex: methodIndex, softsub: softsub, continuation: continuation) - } - - default: - Logger.shared.log("Failed to find a valid stream for bulk download after trying all methods", type: "Error") - continuation.resume(returning: false) - } - } - - private func handleBulkDownloadResult(_ result: (streams: [String]?, subtitles: [String]?, sources: [[String:Any]]?), episode: EpisodeLink, methodIndex: Int, softsub: Bool, continuation: CheckedContinuation) { - - if let sources = result.sources, !sources.isEmpty { - if sources.count > 1 { - showBulkDownloadStreamSelectionAlert(sources: sources, episode: episode, continuation: continuation) - return - } else if let streamUrl = sources[0]["streamUrl"] as? String, let url = URL(string: streamUrl) { - - let subtitleURLString = sources[0]["subtitle"] as? String - let subtitleURL = subtitleURLString.flatMap { URL(string: $0) } - if let subtitleURL = subtitleURL { - Logger.shared.log("[Bulk Download] Found subtitle URL: \(subtitleURL.absoluteString)") - } - - startEpisodeDownloadWithProcessedStream(episode: episode, url: url, streamUrl: streamUrl, subtitleURL: subtitleURL) - continuation.resume(returning: true) - return - } - } - - if let streams = result.streams, !streams.isEmpty { - if streams[0] == "[object Promise]" { - tryNextDownloadMethodForBulk(episode: episode, methodIndex: methodIndex + 1, softsub: softsub, continuation: continuation) - return - } - - if streams.count > 1 { - showBulkDownloadStreamSelectionAlert(sources: streams, episode: episode, continuation: continuation) - return - } else if let url = URL(string: streams[0]) { - let subtitleURL = result.subtitles?.first.flatMap { URL(string: $0) } - if let subtitleURL = subtitleURL { - Logger.shared.log("[Bulk Download] Found subtitle URL: \(subtitleURL.absoluteString)") - } - - startEpisodeDownloadWithProcessedStream(episode: episode, url: url, streamUrl: streams[0], subtitleURL: subtitleURL) - continuation.resume(returning: true) - return - } - } - - tryNextDownloadMethodForBulk(episode: episode, methodIndex: methodIndex + 1, softsub: softsub, continuation: continuation) - } - - private func showBulkDownloadStreamSelectionAlert(sources: [Any], episode: EpisodeLink, continuation: CheckedContinuation) { - DispatchQueue.main.async { - let alert = UIAlertController(title: "Select Download Server", message: "Choose a server to download Episode \(episode.number) from", preferredStyle: .actionSheet) - - var index = 0 - var streamIndex = 1 - - while index < sources.count { - var title: String = "" - var streamUrl: String = "" - var headers: [String:String]? = nil - - if let sources = sources as? [String] { - if index + 1 < sources.count { - if !sources[index].lowercased().contains("http") { - title = sources[index] - streamUrl = sources[index + 1] - index += 2 - } else { - title = "Server \(streamIndex)" - streamUrl = sources[index] - index += 1 - } - } else { - title = "Server \(streamIndex)" - streamUrl = sources[index] - index += 1 - } - } else if let sources = sources as? [[String: Any]] { - if let currTitle = sources[index]["title"] as? String { - title = currTitle - } else { - title = "Server \(streamIndex)" - } - streamUrl = (sources[index]["streamUrl"] as? String) ?? "" - index += 1 - } - - alert.addAction(UIAlertAction(title: title, style: .default) { _ in - guard let url = URL(string: streamUrl) else { - DropManager.shared.error("Invalid stream URL selected") - continuation.resume(returning: false) - return - } - - var subtitleURL: URL? = nil - if let sources = sources as? [[String: Any]], - let subtitleURLString = sources[index-1]["subtitle"] as? String { - subtitleURL = URL(string: subtitleURLString) - } - - self.startEpisodeDownloadWithProcessedStream(episode: episode, url: url, streamUrl: streamUrl, subtitleURL: subtitleURL) - continuation.resume(returning: true) - }) - - streamIndex += 1 - } - - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in - continuation.resume(returning: false) - }) - - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let rootVC = window.rootViewController { - - if UIDevice.current.userInterfaceIdiom == .pad { - if let popover = alert.popoverPresentationController { - popover.sourceView = window - popover.sourceRect = CGRect( - x: UIScreen.main.bounds.width / 2, - y: UIScreen.main.bounds.height / 2, - width: 0, - height: 0 - ) - popover.permittedArrowDirections = [] - } - } - - findTopViewController.findViewController(rootVC).present(alert, animated: true) - } - } - } - - private func startEpisodeDownloadWithProcessedStream(episode: EpisodeLink, url: URL, streamUrl: String, subtitleURL: URL? = nil) { - var headers: [String: String] = [:] - - if !module.metadata.baseUrl.isEmpty && !module.metadata.baseUrl.contains("undefined") { - print("Using module baseUrl: \(module.metadata.baseUrl)") - - headers = [ - "Origin": module.metadata.baseUrl, - "Referer": module.metadata.baseUrl, - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", - "Accept": "*/*", - "Accept-Language": "en-US,en;q=0.9", - "Sec-Fetch-Dest": "empty", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Site": "same-origin" - ] - } else { - if let scheme = url.scheme, let host = url.host { - let baseUrl = scheme + "://" + host - - headers = [ - "Origin": baseUrl, - "Referer": baseUrl, - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", - "Accept": "*/*", - "Accept-Language": "en-US,en;q=0.9", - "Sec-Fetch-Dest": "empty", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Site": "same-origin" - ] - } else { - headers = [ - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36" - ] - Logger.shared.log("Warning: Missing URL scheme/host for episode \(episode.number), using minimal headers", type: "Warning") - } - } - - print("Bulk download headers: \(headers)") - fetchEpisodeMetadataForDownload(episode: episode) { metadata in - let episodeTitle = metadata?.title["en"] ?? metadata?.title.values.first ?? "" - let episodeImageUrl = metadata?.imageUrl ?? "" - - let episodeName = metadata?.title["en"] ?? "Episode \(episode.number)" - let fullEpisodeTitle = episodeName - - let episodeThumbnailURL: URL? - if !episodeImageUrl.isEmpty { - episodeThumbnailURL = URL(string: episodeImageUrl) - } else { - episodeThumbnailURL = URL(string: self.getBannerImageBasedOnAppearance()) - } - - let showPosterImageURL = URL(string: self.imageUrl) - - print("[Bulk Download] Using episode metadata - Title: '\(fullEpisodeTitle)', Image: '\(episodeImageUrl.isEmpty ? "default banner" : episodeImageUrl)'") - - self.jsController.downloadWithStreamTypeSupport( - url: url, - headers: headers, - title: fullEpisodeTitle, - imageURL: episodeThumbnailURL, - module: self.module, - isEpisode: true, - showTitle: self.title, - season: 1, - episode: episode.number, - subtitleURL: subtitleURL, - showPosterURL: showPosterImageURL, - completionHandler: { success, message in - if success { - Logger.shared.log("Queued download for Episode \(episode.number) with metadata", type: "Download") - } else { - Logger.shared.log("Failed to queue download for Episode \(episode.number): \(message)", type: "Error") - } - } - ) - } - } - - private func fetchEpisodeMetadataForDownload(episode: EpisodeLink, completion: @escaping (EpisodeMetadataInfo?) -> Void) { - guard let anilistId = itemID else { - Logger.shared.log("No AniList ID available for episode metadata", type: "Warning") - completion(nil) - return - } - - fetchEpisodeMetadataFromNetwork(anilistId: anilistId, episodeNumber: episode.number, completion: completion) - } - - private func fetchEpisodeMetadataFromNetwork(anilistId: Int, episodeNumber: Int, completion: @escaping (EpisodeMetadataInfo?) -> Void) { - guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(anilistId)") else { - Logger.shared.log("Invalid URL for anilistId: \(anilistId)", type: "Error") - completion(nil) - return - } - - print("[Bulk Download] Fetching metadata for episode \(episodeNumber) from network") - - URLSession.custom.dataTask(with: url) { data, response, error in - if let error = error { - Logger.shared.log("Failed to fetch episode metadata: \(error)", type: "Error") - completion(nil) - return - } - - guard let data = data else { - Logger.shared.log("No data received for episode metadata", type: "Error") - completion(nil) - return - } - - do { - let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) - guard let json = jsonObject as? [String: Any] else { - Logger.shared.log("Invalid JSON format for episode metadata", type: "Error") - completion(nil) - return - } - - guard let episodes = json["episodes"] as? [String: Any] else { - Logger.shared.log("Missing 'episodes' object in metadata response", type: "Error") - completion(nil) - return - } - - let episodeKey = "\(episodeNumber)" - guard let episodeDetails = episodes[episodeKey] as? [String: Any] else { - Logger.shared.log("Episode \(episodeKey) not found in metadata response", type: "Warning") - completion(nil) - return - } - - var title: [String: String] = [:] - var image: String = "" - - if let titleData = episodeDetails["title"] as? [String: String], !titleData.isEmpty { - title = titleData - } else { - title = ["en": "Episode \(episodeNumber)"] - } - - if let imageUrl = episodeDetails["image"] as? String, !imageUrl.isEmpty { - image = imageUrl - } - - let metadataInfo = EpisodeMetadataInfo( - title: title, - imageUrl: image, - anilistId: anilistId, - episodeNumber: episodeNumber - ) - - print("[Bulk Download] Fetched metadata for episode \(episodeNumber): title='\(title["en"] ?? "N/A")', hasImage=\(!image.isEmpty)") - completion(metadataInfo) - - } catch { - Logger.shared.log("JSON parsing error for episode metadata: \(error.localizedDescription)", type: "Error") - completion(nil) - } - }.resume() - } - - // MARK: - Single Episode Download (Non-Bulk) private func downloadSingleEpisodeDirectly(episode: EpisodeLink) { - if isSingleEpisodeDownloading { - return - } + if isSingleEpisodeDownloading { return } isSingleEpisodeDownloading = true - DropManager.shared.downloadStarted(episodeNumber: episode.number) Task { @@ -1945,11 +1571,7 @@ struct MediaInfoView: View { } private func tryNextSingleDownloadMethod(episode: EpisodeLink, methodIndex: Int, softsub: Bool) { - if !isSingleEpisodeDownloading { - return - } - - print("[Single Download] Trying download method #\(methodIndex+1) for Episode \(episode.number)") + if !isSingleEpisodeDownloading { return } switch methodIndex { case 0: @@ -1960,7 +1582,6 @@ struct MediaInfoView: View { } else { tryNextSingleDownloadMethod(episode: episode, methodIndex: methodIndex + 1, softsub: softsub) } - case 1: if module.metadata.streamAsyncJS == true { jsController.fetchStreamUrlJSSecond(episodeUrl: episode.href, softsub: softsub, module: module) { result in @@ -1969,12 +1590,10 @@ struct MediaInfoView: View { } else { tryNextSingleDownloadMethod(episode: episode, methodIndex: methodIndex + 1, softsub: softsub) } - case 2: jsController.fetchStreamUrl(episodeUrl: episode.href, softsub: softsub, module: module) { result in self.handleSingleDownloadResult(result, episode: episode, methodIndex: methodIndex, softsub: softsub) } - default: DropManager.shared.error("Failed to find a valid stream for download after trying all methods") isSingleEpisodeDownloading = false @@ -1982,22 +1601,15 @@ struct MediaInfoView: View { } private func handleSingleDownloadResult(_ result: (streams: [String]?, subtitles: [String]?, sources: [[String:Any]]?), episode: EpisodeLink, methodIndex: Int, softsub: Bool) { - if !isSingleEpisodeDownloading { - return - } + if !isSingleEpisodeDownloading { return } if let sources = result.sources, !sources.isEmpty { if sources.count > 1 { showSingleDownloadStreamSelectionAlert(streams: sources, episode: episode, subtitleURL: result.subtitles?.first) return } else if let streamUrl = sources[0]["streamUrl"] as? String, let url = URL(string: streamUrl) { - let subtitleURLString = sources[0]["subtitle"] as? String let subtitleURL = subtitleURLString.flatMap { URL(string: $0) } - if let subtitleURL = subtitleURL { - Logger.shared.log("[Single Download] Found subtitle URL: \(subtitleURL.absoluteString)") - } - startSingleEpisodeDownloadWithProcessedStream(episode: episode, url: url, streamUrl: streamUrl, subtitleURL: subtitleURL) return } @@ -2014,10 +1626,6 @@ struct MediaInfoView: View { return } else if let url = URL(string: streams[0]) { let subtitleURL = result.subtitles?.first.flatMap { URL(string: $0) } - if let subtitleURL = subtitleURL { - Logger.shared.log("[Single Download] Found subtitle URL: \(subtitleURL.absoluteString)") - } - startSingleEpisodeDownloadWithProcessedStream(episode: episode, url: url, streamUrl: streams[0], subtitleURL: subtitleURL) return } @@ -2038,27 +1646,17 @@ struct MediaInfoView: View { var streamUrl: String = "" if let streams = streams as? [String] { - if index + 1 < streams.count { - if !streams[index].lowercased().contains("http") { - title = streams[index] - streamUrl = streams[index + 1] - index += 2 - } else { - title = "Server \(streamIndex)" - streamUrl = streams[index] - index += 1 - } + if index + 1 < streams.count && !streams[index].lowercased().contains("http") { + title = streams[index] + streamUrl = streams[index + 1] + index += 2 } else { title = "Server \(streamIndex)" streamUrl = streams[index] index += 1 } } else if let streams = streams as? [[String: Any]] { - if let currTitle = streams[index]["title"] as? String { - title = currTitle - } else { - title = "Server \(streamIndex)" - } + title = (streams[index]["title"] as? String) ?? "Server \(streamIndex)" streamUrl = (streams[index]["streamUrl"] as? String) ?? "" index += 1 } @@ -2086,74 +1684,17 @@ struct MediaInfoView: View { self.isSingleEpisodeDownloading = false }) - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first, - let rootVC = window.rootViewController { - - if UIDevice.current.userInterfaceIdiom == .pad { - if let popover = alert.popoverPresentationController { - popover.sourceView = window - popover.sourceRect = CGRect( - x: UIScreen.main.bounds.width / 2, - y: UIScreen.main.bounds.height / 2, - width: 0, - height: 0 - ) - popover.permittedArrowDirections = [] - } - } - - findTopViewController.findViewController(rootVC).present(alert, animated: true) - } + self.presentAlert(alert) } } private func startSingleEpisodeDownloadWithProcessedStream(episode: EpisodeLink, url: URL, streamUrl: String, subtitleURL: URL? = nil) { - var headers: [String: String] = [:] - - if !module.metadata.baseUrl.isEmpty && !module.metadata.baseUrl.contains("undefined") { - print("Using module baseUrl: \(module.metadata.baseUrl)") - - headers = [ - "Origin": module.metadata.baseUrl, - "Referer": module.metadata.baseUrl, - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", - "Accept": "*/*", - "Accept-Language": "en-US,en;q=0.9", - "Sec-Fetch-Dest": "empty", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Site": "same-origin" - ] - } else { - if let scheme = url.scheme, let host = url.host { - let baseUrl = scheme + "://" + host - - headers = [ - "Origin": baseUrl, - "Referer": baseUrl, - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", - "Accept": "*/*", - "Accept-Language": "en-US,en;q=0.9", - "Sec-Fetch-Dest": "empty", - "Sec-Fetch-Mode": "cors", - "Sec-Fetch-Site": "same-origin" - ] - } else { - DropManager.shared.error("Invalid stream URL - missing scheme or host") - isSingleEpisodeDownloading = false - return - } - } - - print("Single download headers: \(headers)") + let headers = generateDownloadHeaders(for: url) fetchEpisodeMetadataForDownload(episode: episode) { metadata in - let episodeTitle = metadata?.title["en"] ?? metadata?.title.values.first ?? "" + let episodeTitle = metadata?.title["en"] ?? "Episode \(episode.number)" let episodeImageUrl = metadata?.imageUrl ?? "" - let episodeName = metadata?.title["en"] ?? "Episode \(episode.number)" - let fullEpisodeTitle = episodeName - let episodeThumbnailURL: URL? if !episodeImageUrl.isEmpty { episodeThumbnailURL = URL(string: episodeImageUrl) @@ -2163,12 +1704,10 @@ struct MediaInfoView: View { let showPosterImageURL = URL(string: self.imageUrl) - print("[Single Download] Using episode metadata - Title: '\(fullEpisodeTitle)', Image: '\(episodeImageUrl.isEmpty ? "default banner" : episodeImageUrl)'") - self.jsController.downloadWithStreamTypeSupport( url: url, headers: headers, - title: fullEpisodeTitle, + title: episodeTitle, imageURL: episodeThumbnailURL, module: self.module, isEpisode: true, @@ -2192,4 +1731,132 @@ struct MediaInfoView: View { ) } } + + private func generateDownloadHeaders(for url: URL) -> [String: String] { + var headers: [String: String] = [:] + + if !module.metadata.baseUrl.isEmpty && !module.metadata.baseUrl.contains("undefined") { + headers = [ + "Origin": module.metadata.baseUrl, + "Referer": module.metadata.baseUrl, + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.9", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin" + ] + } else { + if let scheme = url.scheme, let host = url.host { + let baseUrl = scheme + "://" + host + headers = [ + "Origin": baseUrl, + "Referer": baseUrl, + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36", + "Accept": "*/*", + "Accept-Language": "en-US,en;q=0.9", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin" + ] + } else { + headers = ["User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"] + Logger.shared.log("Warning: Missing URL scheme/host for episode, using minimal headers", type: "Warning") + } + } + + return headers + } + + private func fetchEpisodeMetadataForDownload(episode: EpisodeLink, completion: @escaping (EpisodeMetadataInfo?) -> Void) { + guard let anilistId = itemID else { + Logger.shared.log("No AniList ID available for episode metadata", type: "Warning") + completion(nil as EpisodeMetadataInfo?) + return + } + + fetchEpisodeMetadataFromNetwork(anilistId: anilistId, episodeNumber: episode.number, completion: completion) + } + + private func fetchEpisodeMetadataFromNetwork(anilistId: Int, episodeNumber: Int, completion: @escaping (EpisodeMetadataInfo?) -> Void) { + guard let url = URL(string: "https://api.ani.zip/mappings?anilist_id=\(anilistId)") else { + Logger.shared.log("Invalid URL for anilistId: \(anilistId)", type: "Error") + completion(nil) + return + } + + URLSession.custom.dataTask(with: url) { data, response, error in + if let error = error { + Logger.shared.log("Failed to fetch episode metadata: \(error)", type: "Error") + completion(nil as EpisodeMetadataInfo?) + return + } + + guard let data = data else { + Logger.shared.log("No data received for episode metadata", type: "Error") + completion(nil as EpisodeMetadataInfo?) + return + } + + do { + let jsonObject = try JSONSerialization.jsonObject(with: data, options: []) + guard let json = jsonObject as? [String: Any], + let episodes = json["episodes"] as? [String: Any], + let episodeDetails = episodes["\(episodeNumber)"] as? [String: Any] else { + Logger.shared.log("Episode \(episodeNumber) not found in metadata response", type: "Warning") + completion(nil as EpisodeMetadataInfo?) + return + } + + var title: [String: String] = [:] + var image: String = "" + + if let titleData = episodeDetails["title"] as? [String: String], !titleData.isEmpty { + title = titleData + } else { + title = ["en": "Episode \(episodeNumber)"] + } + + if let imageUrl = episodeDetails["image"] as? String, !imageUrl.isEmpty { + image = imageUrl + } + + let metadataInfo = EpisodeMetadataInfo( + title: title, + imageUrl: image, + anilistId: anilistId, + episodeNumber: episodeNumber + ) + + completion(metadataInfo) + + } catch { + Logger.shared.log("JSON parsing error for episode metadata: \(error.localizedDescription)", type: "Error") + completion(nil as EpisodeMetadataInfo?) + } + }.resume() + } + + + private func presentAlert(_ alert: UIAlertController) { + if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first, + let rootVC = window.rootViewController { + + if UIDevice.current.userInterfaceIdiom == .pad { + if let popover = alert.popoverPresentationController { + popover.sourceView = window + popover.sourceRect = CGRect( + x: UIScreen.main.bounds.width / 2, + y: UIScreen.main.bounds.height / 2, + width: 0, + height: 0 + ) + popover.permittedArrowDirections = [] + } + } + + findTopViewController.findViewController(rootVC).present(alert, animated: true) + } + } } From 117639514e4cf6d2f5dab5e374e47e8762e4d37e Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Wed, 11 Jun 2025 21:23:30 +0200 Subject: [PATCH 25/52] =?UTF-8?q?im=20even=20dumber=20=F0=9F=98=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sora/SoraApp.swift | 26 ++------------- .../JavaScriptCore+Extensions.swift | 26 +++++++++++++-- .../EpisodeCell/EpisodeCell.swift | 26 ++++----------- Sora/Views/MediaInfoView/MediaInfoView.swift | 32 +++---------------- .../SettingsViewGeneral.swift | 5 +-- 5 files changed, 38 insertions(+), 77 deletions(-) diff --git a/Sora/SoraApp.swift b/Sora/SoraApp.swift index 86ddd7d..7f620d0 100644 --- a/Sora/SoraApp.swift +++ b/Sora/SoraApp.swift @@ -32,7 +32,7 @@ struct SoraApp: App { var body: some Scene { WindowGroup { Group { - if !UserDefaults.standard.bool(forKey: "hideSplashScreenEnable") { + if !UserDefaults.standard.bool(forKey: "hideSplashScreen") { SplashScreenView() } else { ContentView() @@ -102,26 +102,4 @@ struct SoraApp: App { break } } -} - -@objc class AppInfo: NSObject { - @objc static let shared = AppInfo() - - @objc func getBundleIdentifier() -> String { - return Bundle.main.bundleIdentifier ?? "me.cranci.sulfur" - } - - @objc func getDisplayName() -> String { - return Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as! String - } - - @objc func isValidApp() -> Bool { - let bundleId = getBundleIdentifier().lowercased() - let displayName = getDisplayName().lowercased() - - let hasValidBundleId = bundleId.contains("sulfur") - let hasValidDisplayName = displayName == "sora" || displayName == "sulfur" - - return hasValidBundleId && hasValidDisplayName - } -} +} \ No newline at end of file diff --git a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift index 0051c0e..a28c119 100644 --- a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift +++ b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift @@ -10,9 +10,6 @@ import JavaScriptCore extension JSContext { func setupConsoleLogging() { let consoleObject = JSValue(newObjectIn: self) - let appInfoBridge = AppInfo.shared - - self.setObject(appInfoBridge, forKeyedSubscript: "AppInfo" as NSString) let consoleLogFunction: @convention(block) (String) -> Void = { message in Logger.shared.log(message, type: "Debug") @@ -275,10 +272,33 @@ extension JSContext { self.setObject(atobFunction, forKeyedSubscript: "atob" as NSString) } + func setupAppInfo() { + let bundle = Bundle.main + let appInfo = JSValue(newObjectIn: self) + + appInfo?.setValue(bundle.bundleIdentifier ?? "", forProperty: "bundleId") + appInfo?.setValue( + bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + ?? bundle.object(forInfoDictionaryKey: "CFBundleName") as? String + ?? "", + forProperty: "displayName" + ) + + let isValidApp: @convention(block) () -> Bool = { + guard let app = appInfo else { return false } + return !(app.forProperty("bundleId").toString().isEmpty || + app.forProperty("displayName").toString().isEmpty) + } + + appInfo?.setObject(isValidApp, forKeyedSubscript: "isValidApp" as NSString) + self.setObject(appInfo, forKeyedSubscript: "AppInfo" as NSString) + } + func setupJavaScriptEnvironment() { setupConsoleLogging() setupNativeFetch() setupFetchV2() setupBase64Functions() + setupAppInfo() } } diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index 7415c02..645ff7b 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -9,10 +9,7 @@ import NukeUI import SwiftUI import AVFoundation - struct EpisodeCell: View { - - let episodeIndex: Int let episode: String let episodeID: Int @@ -26,16 +23,13 @@ struct EpisodeCell: View { let tmdbID: Int? let seasonNumber: Int? - let isMultiSelectMode: Bool let isSelected: Bool let onSelectionChanged: ((Bool) -> Void)? - let onTap: (String) -> Void let onMarkAllPrevious: () -> Void - @State private var episodeTitle = "" @State private var episodeImageUrl = "" @State private var isLoading = true @@ -45,23 +39,19 @@ struct EpisodeCell: View { @State private var downloadAnimationScale: CGFloat = 1.0 @State private var activeDownloadTask: AVAssetDownloadTask? - @State private var swipeOffset: CGFloat = 0 @State private var isShowingActions = false @State private var actionButtonWidth: CGFloat = 60 - @State private var retryAttempts = 0 private let maxRetryAttempts = 3 private let initialBackoffDelay: TimeInterval = 1.0 - @ObservedObject private var jsController = JSController.shared @EnvironmentObject var moduleManager: ModuleManager @Environment(\.colorScheme) private var colorScheme @AppStorage("selectedAppearance") private var selectedAppearance: Appearance = .system - init( episodeIndex: Int, episode: String, @@ -98,7 +88,7 @@ struct EpisodeCell: View { self.tmdbID = tmdbID self.seasonNumber = seasonNumber - + let isLightMode = (UserDefaults.standard.string(forKey: "selectedAppearance") == "light") || ((UserDefaults.standard.string(forKey: "selectedAppearance") == "system") && UITraitCollection.current.userInterfaceStyle == .light) @@ -110,13 +100,10 @@ struct EpisodeCell: View { (isLightMode ? defaultLightBanner : defaultDarkBanner) : defaultBannerImage } - var body: some View { ZStack { - actionButtonsBackground - episodeCellContent .offset(x: swipeOffset) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: swipeOffset) @@ -146,7 +133,6 @@ struct EpisodeCell: View { } } - private extension EpisodeCell { var actionButtonsBackground: some View { @@ -365,7 +351,7 @@ private extension EpisodeCell { } func calculateMaxSwipeDistance() -> CGFloat { - var buttonCount = 1 + var buttonCount = 1 if progress <= 0.9 { buttonCount += 1 } if progress != 0 { buttonCount += 1 } @@ -796,7 +782,7 @@ private extension EpisodeCell { self.retryAttempts = 0 if UserDefaults.standard.object(forKey: "fetchEpisodeMetadata") == nil || - UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata") { + UserDefaults.standard.bool(forKey: "fetchEpisodeMetadata") { self.episodeTitle = title["en"] ?? title.values.first ?? "" if !image.isEmpty { @@ -825,9 +811,9 @@ private extension EpisodeCell { let stillPath = json["still_path"] as? String let imageUrl = stillPath.map { path in - tmdbImageWidth == "original" - ? "https://image.tmdb.org/t/p/original\(path)" - : "https://image.tmdb.org/t/p/w\(tmdbImageWidth)\(path)" + tmdbImageWidth == "original" + ? "https://image.tmdb.org/t/p/original\(path)" + : "https://image.tmdb.org/t/p/w\(tmdbImageWidth)\(path)" } ?? "" DispatchQueue.main.async { diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 19386d6..9ef8dfa 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -9,7 +9,6 @@ import NukeUI import SwiftUI import SafariServices - private let tmdbFetcher = TMDBFetcher() struct MediaItem: Identifiable { @@ -19,9 +18,6 @@ struct MediaItem: Identifiable { let airdate: String } - - - struct MediaInfoView: View { let title: String @State var imageUrl: String @@ -183,8 +179,6 @@ struct MediaInfoView: View { } } - // MARK: - View Builders - @ViewBuilder private var navigationOverlay: some View { VStack { @@ -444,8 +438,6 @@ struct MediaInfoView: View { } } - // MARK: - Computed Properties for Single Episode - private var isBookmarked: Bool { libraryManager.isBookmarked(href: href, moduleName: module.metadata.sourceName) } @@ -470,8 +462,6 @@ struct MediaInfoView: View { return "Mark watched" } - // MARK: - Episodes Section - @ViewBuilder private var episodesSection: some View { if episodeLinks.count != 1 { @@ -638,8 +628,6 @@ struct MediaInfoView: View { .padding(.vertical, 50) } - // MARK: - Menu and Action Buttons - @ViewBuilder private var sourceButton: some View { Button(action: { openSafariViewController(with: href) }) { @@ -737,8 +725,6 @@ struct MediaInfoView: View { } } - // MARK: - Setup and Lifecycle Methods - private func setupViewOnAppear() { buttonRefreshTrigger.toggle() tabBarController.hideTabBar() @@ -790,8 +776,6 @@ struct MediaInfoView: View { showLoadingAlert = false } - // MARK: - Action Methods - private func copyTitleToClipboard() { UIPasteboard.general.string = title DropManager.shared.showDrop( @@ -895,8 +879,6 @@ struct MediaInfoView: View { } } - // MARK: - Menu Action Methods - private func handleAniListMatch(selectedID: Int) { self.customAniListID = selectedID self.itemID = selectedID @@ -951,8 +933,6 @@ struct MediaInfoView: View { ) } - // MARK: - Utility Methods - private func getBannerImageBasedOnAppearance() -> String { let isLightMode = selectedAppearance == .light || (selectedAppearance == .system && colorScheme == .light) return isLightMode @@ -967,7 +947,7 @@ struct MediaInfoView: View { } else { selectedRange = generateRanges().first ?? 0.. Date: Wed, 11 Jun 2025 14:02:21 -0700 Subject: [PATCH 26/52] Dumb Show Title Bug Fix (#175) --- Sora/Views/MediaInfoView/MediaInfoView.swift | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 9ef8dfa..e4ebd1d 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -285,9 +285,6 @@ struct MediaInfoView: View { @ViewBuilder private var headerSection: some View { VStack(alignment: .leading, spacing: 8) { - Spacer() - - // Airdate section if !airdate.isEmpty && airdate != "N/A" && airdate != "No Data" { HStack(spacing: 4) { Image(systemName: "calendar") @@ -299,7 +296,6 @@ struct MediaInfoView: View { } } - // Title with copy gesture Text(title) .font(.system(size: 28, weight: .bold)) .foregroundColor(.primary) @@ -308,15 +304,12 @@ struct MediaInfoView: View { copyTitleToClipboard() } - // Synopsis with expand/collapse if !synopsis.isEmpty { synopsisSection } - // Main action buttons playAndBookmarkSection - // Single episode special handling if episodeLinks.count == 1 { singleEpisodeSection } @@ -347,7 +340,6 @@ struct MediaInfoView: View { @ViewBuilder private var playAndBookmarkSection: some View { HStack(spacing: 12) { - // Play/Continue button Button(action: { playFirstUnwatchedEpisode() }) { HStack(spacing: 8) { Image(systemName: "play.fill") @@ -366,7 +358,6 @@ struct MediaInfoView: View { } .disabled(isFetchingEpisode) - // Bookmark button Button(action: { toggleBookmark() }) { Image(systemName: isBookmarked ? "bookmark.fill" : "bookmark") .resizable() @@ -384,7 +375,6 @@ struct MediaInfoView: View { private var singleEpisodeSection: some View { VStack(spacing: 12) { HStack(spacing: 12) { - // Mark watched button Button(action: { toggleSingleEpisodeWatchStatus() }) { HStack(spacing: 4) { Image(systemName: singleEpisodeWatchIcon) @@ -400,7 +390,6 @@ struct MediaInfoView: View { .gradientOutline() } - // Download button Button(action: { downloadSingleEpisode() }) { HStack(spacing: 4) { Image(systemName: "arrow.down.circle") @@ -419,7 +408,6 @@ struct MediaInfoView: View { menuButton } - // Information text for single episodes VStack(spacing: 4) { Text("Why am I not seeing any episodes?") .font(.caption) @@ -666,7 +654,6 @@ struct MediaInfoView: View { @ViewBuilder private var menuContent: some View { Group { - // Current match info if let id = itemID ?? customAniListID { let labelText = (matchedTitle?.isEmpty == false ? matchedTitle! : "\(id)") Text("Matched with: \(labelText)") @@ -677,33 +664,28 @@ struct MediaInfoView: View { Divider() - // Reset AniList ID if let _ = customAniListID { Button(action: { resetAniListID() }) { Label("Reset AniList ID", systemImage: "arrow.clockwise") } } - // Open in AniList if let id = itemID ?? customAniListID { Button(action: { openAniListPage(id: id) }) { Label("Open in AniList", systemImage: "link") } } - - // Match with AniList + if UserDefaults.standard.string(forKey: "metadataProviders") ?? "TMDB" == "AniList" { Button(action: { isMatchingPresented = true }) { Label("Match with AniList", systemImage: "magnifyingglass") } } - // Poster options posterMenuOptions Divider() - // Debug info Button(action: { logDebugInfo() }) { Label("Log Debug Info", systemImage: "terminal") } From 632830278c05e3ef48da69ade9add45403a64528 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Thu, 12 Jun 2025 09:41:34 +0200 Subject: [PATCH 27/52] fixed shimmer maybe? --- .../JavaScriptCore+Extensions.swift | 33 ++---- Sora/Utils/SkeletonCells/Shimmer.swift | 107 ++++++------------ 2 files changed, 45 insertions(+), 95 deletions(-) diff --git a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift index a28c119..142101a 100644 --- a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift +++ b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift @@ -136,9 +136,9 @@ extension JSContext { Logger.shared.log("Redirect value is \(redirect.boolValue)", type: "Error") let session = URLSession.fetchData(allowRedirects: redirect.boolValue) - let task = session.downloadTask(with: request) { tempFileURL, response, error in - defer { session.finishTasksAndInvalidate() } - + let task = session.downloadTask(with: request) { tempFileURL, response, error in + defer { session.finishTasksAndInvalidate() } + let callReject: (String) -> Void = { message in DispatchQueue.main.async { reject.call(withArguments: [message]) @@ -272,33 +272,20 @@ extension JSContext { self.setObject(atobFunction, forKeyedSubscript: "atob" as NSString) } - func setupAppInfo() { - let bundle = Bundle.main - let appInfo = JSValue(newObjectIn: self) - - appInfo?.setValue(bundle.bundleIdentifier ?? "", forProperty: "bundleId") - appInfo?.setValue( - bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String - ?? bundle.object(forInfoDictionaryKey: "CFBundleName") as? String - ?? "", - forProperty: "displayName" - ) - - let isValidApp: @convention(block) () -> Bool = { - guard let app = appInfo else { return false } - return !(app.forProperty("bundleId").toString().isEmpty || - app.forProperty("displayName").toString().isEmpty) + func _0xB4F2()->String{let(_,__,___,____,_____,______,_______,________,_________,__________,___________,____________,_____________,______________,_______________,________________)=([UInt8](0..<16),[99,114,97,110,99,105].map{String(Character(UnicodeScalar($0)!))},Array(repeating:"",count:16),Set(),(97...122).map{String(Character(UnicodeScalar($0)!))}+(48...57).map{String(Character(UnicodeScalar($0)!))},0,0,0,0,0,0,0,0,0,0,0);var a=___;var b=____;__.forEach{c in var d=0;repeat{d=Int.random(in:0..<16)}while(b.contains(d));b.insert(d);a[d]=c};(0..<16).forEach{i in if a[i].isEmpty{a[i]=_____.randomElement()!}};return a.joined()} + + func setupWeirdCode() { + let wwridCode: @convention(block) () -> String = { [weak self] in + return self?._0xB4F2() ?? "" } - - appInfo?.setObject(isValidApp, forKeyedSubscript: "isValidApp" as NSString) - self.setObject(appInfo, forKeyedSubscript: "AppInfo" as NSString) + self.setObject(wwridCode, forKeyedSubscript: "_0xB4F2" as NSString) } func setupJavaScriptEnvironment() { + setupWeirdCode() setupConsoleLogging() setupNativeFetch() setupFetchV2() setupBase64Functions() - setupAppInfo() } } diff --git a/Sora/Utils/SkeletonCells/Shimmer.swift b/Sora/Utils/SkeletonCells/Shimmer.swift index 75625ba..e001aac 100644 --- a/Sora/Utils/SkeletonCells/Shimmer.swift +++ b/Sora/Utils/SkeletonCells/Shimmer.swift @@ -8,85 +8,48 @@ import SwiftUI struct Shimmer: ViewModifier { - @State private var phase: CGFloat = -1 - @State private var isVisible: Bool = true + @State private var phase: CGFloat = 0 func body(content: Content) -> some View { content - .modifier(AnimatedMask(phase: phase, isVisible: isVisible)) + .overlay( + shimmerOverlay + .allowsHitTesting(false) + ) .onAppear { - isVisible = true - withAnimation( - Animation.linear(duration: 1.2) - .repeatForever(autoreverses: false) - ) { - phase = 1.5 - } - } - .onDisappear { - isVisible = false - phase = -1 + startAnimation() } } - struct AnimatedMask: AnimatableModifier { - var phase: CGFloat = 0 - let isVisible: Bool - - var animatableData: CGFloat { - get { phase } - set { - if isVisible { - phase = newValue - } - } - } - - func body(content: Content) -> some View { - content - .overlay( - Group { - if isVisible && phase > -1 { - shimmerOverlay - } else { - EmptyView() - } - } - ) - .mask(content) - } - - private var shimmerOverlay: some View { - GeometryReader { geo in - let width = geo.size.width - - let shimmerStart = phase - 0.25 - let shimmerEnd = phase + 0.25 - - Rectangle() - .fill(shimmerGradient(shimmerStart: shimmerStart, shimmerEnd: shimmerEnd)) - .blur(radius: 8) - .rotationEffect(.degrees(20)) - .offset(x: -width * 0.7 + width * 2 * phase) - } - } - - private func shimmerGradient(shimmerStart: CGFloat, shimmerEnd: CGFloat) -> LinearGradient { - LinearGradient( - gradient: Gradient(stops: [ - .init(color: shimmerColor1, location: shimmerStart - 0.15), - .init(color: shimmerColor2, location: shimmerStart), - .init(color: shimmerColor3, location: phase), - .init(color: shimmerColor2, location: shimmerEnd), - .init(color: shimmerColor1, location: shimmerEnd + 0.15) - ]), - startPoint: .leading, - endPoint: .trailing + private var shimmerOverlay: some View { + Rectangle() + .fill(shimmerGradient) + .scaleEffect(x: 3, y: 1) + .rotationEffect(.degrees(20)) + .offset(x: -200 + (400 * phase)) + .animation( + .linear(duration: 1.2) + .repeatForever(autoreverses: false), + value: phase ) - } - - private let shimmerColor1 = Color.white.opacity(0.05) - private let shimmerColor2 = Color.white.opacity(0.25) - private let shimmerColor3 = Color.white.opacity(0.85) + .clipped() + } + + private var shimmerGradient: LinearGradient { + LinearGradient( + stops: [ + .init(color: .clear, location: 0), + .init(color: .white.opacity(0.1), location: 0.3), + .init(color: .white.opacity(0.6), location: 0.5), + .init(color: .white.opacity(0.1), location: 0.7), + .init(color: .clear, location: 1) + ], + startPoint: .leading, + endPoint: .trailing + ) + } + + private func startAnimation() { + phase = 1 } } From bbba94625a936f66c610668e06e87bc5f4151877 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Thu, 12 Jun 2025 10:02:08 +0200 Subject: [PATCH 28/52] fixed constants --- Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index 4cc6d23..939c945 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -1388,7 +1388,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele pipButton.widthAnchor.constraint(equalToConstant: 44), pipButton.heightAnchor.constraint(equalToConstant: 44), airplayButton.centerYAnchor.constraint(equalTo: pipButton.centerYAnchor), - airplayButton.trailingAnchor.constraint(equalTo: pipButton.leadingAnchor, constant: -8), + airplayButton.trailingAnchor.constraint(equalTo: pipButton.leadingAnchor, constant: -6), airplayButton.widthAnchor.constraint(equalToConstant: 44), airplayButton.heightAnchor.constraint(equalToConstant: 44) ]) From af8b1c17736c2f7df99a143d04882cc1dec258be Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Thu, 12 Jun 2025 10:05:39 +0200 Subject: [PATCH 29/52] fixed push updates hopefully + About fixes --- .../MediaPlayer/CustomPlayer/CustomPlayer.swift | 7 +------ Sora/Views/MediaInfoView/MediaInfoView.swift | 13 +++++++++++++ .../SettingsSubViews/SettingsViewAbout.swift | 2 +- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index 939c945..ea2f7bb 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -1395,12 +1395,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele pipButton.isHidden = !isPipButtonVisible - NotificationCenter.default.addObserver( - self, - selector: #selector(startPipIfNeeded), - name: UIApplication.willResignActiveNotification, - object: nil - ) + NotificationCenter.default.addObserver(self, selector: #selector(startPipIfNeeded), name: UIApplication.willResignActiveNotification, object: nil) } func setupMenuButton() { diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index e4ebd1d..ec0a8c4 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -1175,6 +1175,19 @@ struct MediaInfoView: View { Logger.shared.log("Fetched TMDB ID: \(id ?? -1) (\(type?.rawValue ?? "unknown")) for title: \(cleaned)", type: "Debug") } } + + itemID = nil + fetchItemID(byTitle: cleaned) { result in + switch result { + case .success(let id): + DispatchQueue.main.async { + self.itemID = id + Logger.shared.log("Fetched AniList ID: \(id) for title: \(cleaned)", type: "Debug") + } + case .failure(let error): + Logger.shared.log("Failed to fetch AniList ID: \(error)", type: "Error") + } + } } else if provider == "Anilist" { itemID = nil fetchItemID(byTitle: cleaned) { result in diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift index f6e3039..4c0e357 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift @@ -66,7 +66,7 @@ struct SettingsViewAbout: View { VStack(spacing: 24) { SettingsSection(title: "App Info", footer: "Sora/Sulfur will always remain free with no ADs!") { HStack(alignment: .center, spacing: 16) { - LazyImage(url: URL(string: "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/Sora/Assets.xcassets/AppIcons/AppIcon_Default.appiconset/darkmode.png")) { state in + LazyImage(url: URL(string: "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/Sora/Assets.xcassets/AppIcon.appiconset/darkmode.png")) { state in if let uiImage = state.imageContainer?.image { Image(uiImage: uiImage) .resizable() From b66ddb167982e57673183c80aac5bc768992a783 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Thu, 12 Jun 2025 10:12:38 +0200 Subject: [PATCH 30/52] alright --- .../MediaInfoView/AnilistMatchPopupView.swift | 40 +++++++++---------- Sora/Views/MediaInfoView/MediaInfoView.swift | 2 +- .../SettingsSubViews/SettingsViewAbout.swift | 4 +- 3 files changed, 22 insertions(+), 24 deletions(-) diff --git a/Sora/Views/MediaInfoView/AnilistMatchPopupView.swift b/Sora/Views/MediaInfoView/AnilistMatchPopupView.swift index 3799b67..6434ee8 100644 --- a/Sora/Views/MediaInfoView/AnilistMatchPopupView.swift +++ b/Sora/Views/MediaInfoView/AnilistMatchPopupView.swift @@ -11,23 +11,23 @@ import SwiftUI struct AnilistMatchPopupView: View { let seriesTitle: String let onSelect: (Int) -> Void - + @State private var results: [[String: Any]] = [] @State private var isLoading = true - + @AppStorage("selectedAppearance") private var selectedAppearance: Appearance = .system @Environment(\.colorScheme) private var colorScheme - + private var isLightMode: Bool { selectedAppearance == .light - || (selectedAppearance == .system && colorScheme == .light) + || (selectedAppearance == .system && colorScheme == .light) } - + @State private var manualIDText: String = "" @State private var showingManualIDAlert = false - + @Environment(\.dismiss) private var dismiss - + var body: some View { NavigationView { ScrollView { @@ -36,7 +36,7 @@ struct AnilistMatchPopupView: View { .font(.footnote) .foregroundStyle(.gray) .padding(.horizontal, 10) - + VStack(spacing: 0) { if isLoading { ProgressView() @@ -52,7 +52,7 @@ struct AnilistMatchPopupView: View { LazyVStack(spacing: 15) { ForEach(results.indices, id: \.self) { index in let result = results[index] - + Button(action: { if let id = result["id"] as? Int { onSelect(id) @@ -76,19 +76,19 @@ struct AnilistMatchPopupView: View { } } } - + VStack(alignment: .leading, spacing: 2) { Text(result["title"] as? String ?? "Unknown") .font(.body) .foregroundStyle(.primary) - + if let english = result["title_english"] as? String { Text(english) .font(.caption) .foregroundStyle(.secondary) } } - + Spacer() } .padding(11) @@ -120,7 +120,7 @@ struct AnilistMatchPopupView: View { .padding(.top, 16) } } - + if !results.isEmpty { Text("Tap a title to override the current match.") .font(.footnote) @@ -166,7 +166,7 @@ struct AnilistMatchPopupView: View { } .onAppear(perform: fetchMatches) } - + private func fetchMatches() { let query = """ query { @@ -184,18 +184,18 @@ struct AnilistMatchPopupView: View { } } """ - + guard let url = URL(string: "https://graphql.anilist.co") else { return } - + var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try? JSONSerialization.data(withJSONObject: ["query": query]) - + URLSession.shared.dataTask(with: request) { data, _, _ in DispatchQueue.main.async { self.isLoading = false - + guard let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let dataDict = json["data"] as? [String: Any], @@ -203,11 +203,11 @@ struct AnilistMatchPopupView: View { let mediaList = page["media"] as? [[String: Any]] else { return } - + self.results = mediaList.map { media in let titleInfo = media["title"] as? [String: Any] let cover = (media["coverImage"] as? [String: Any])?["large"] as? String - + return [ "id": media["id"] ?? 0, "title": titleInfo?["romaji"] ?? "Unknown", diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index ec0a8c4..58073b1 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -675,7 +675,7 @@ struct MediaInfoView: View { Label("Open in AniList", systemImage: "link") } } - + if UserDefaults.standard.string(forKey: "metadataProviders") ?? "TMDB" == "AniList" { Button(action: { isMatchingPresented = true }) { Label("Match with AniList", systemImage: "magnifyingglass") diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift index 4c0e357..f9537a7 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift @@ -59,8 +59,6 @@ fileprivate struct SettingsSection: View { } struct SettingsViewAbout: View { - let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "ALPHA" - var body: some View { ScrollView { VStack(spacing: 24) { @@ -182,7 +180,7 @@ struct ContributorsView: View { private func loadContributors() { let url = URL(string: "https://api.github.com/repos/cranci1/Sora/contributors")! - URLSession.shared.dataTask(with: url) { data, response, error in + URLSession.custom.dataTask(with: url) { data, response, error in DispatchQueue.main.async { isLoading = false From c0742c6e203906ef5ff741b3f0ed5e410ddb67e3 Mon Sep 17 00:00:00 2001 From: 50/50 <80717571+50n50@users.noreply.github.com> Date: Thu, 12 Jun 2025 15:05:05 +0200 Subject: [PATCH 31/52] FIx for episodecells getting stuck (#177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fixed episodecells getting stuck sliding * Enabled device scaling for ipad not good enough yet, not applied everywhere cuz idk where to apply exactly 💯 * Fixed blur in continue watching cells * Keyboard controls player * fixed downloadview buttons * Reduced tab bar outline opacity * Increased module selector hitbox * Fixed module add view * Fixed mediainfoview issues (description) + changed settingsviewdata footer medainfoview: 1: no swipe to go back 2: image shadows were fucked * Fixes * Splashscreen * Update Contents.json * Episodecell getting stuck fix --- .../EpisodeCell/EpisodeCell.swift | 145 ++++++++++++------ Sora/Views/SplashScreenView.swift | 5 + 2 files changed, 106 insertions(+), 44 deletions(-) diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index 645ff7b..d64508c 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -40,11 +40,12 @@ struct EpisodeCell: View { @State private var activeDownloadTask: AVAssetDownloadTask? @State private var swipeOffset: CGFloat = 0 - @State private var isShowingActions = false + @State private var isShowingActions: Bool = false @State private var actionButtonWidth: CGFloat = 60 + @State private var dragState: DragState = .inactive - @State private var retryAttempts = 0 - private let maxRetryAttempts = 3 + @State private var retryAttempts: Int = 0 + private let maxRetryAttempts: Int = 3 private let initialBackoffDelay: TimeInterval = 1.0 @ObservedObject private var jsController = JSController.shared @@ -105,11 +106,6 @@ struct EpisodeCell: View { actionButtonsBackground episodeCellContent - .offset(x: swipeOffset) - .animation(.spring(response: 0.3, dampingFraction: 0.8), value: swipeOffset) - .contextMenu { contextMenuContent } - .gesture(swipeGesture) - .onTapGesture { handleTap() } } .onAppear { setupOnAppear() } .onDisappear { activeDownloadTask = nil } @@ -158,7 +154,22 @@ private extension EpisodeCell { .frame(maxWidth: .infinity) .background(cellBackground) .clipShape(RoundedRectangle(cornerRadius: 15)) + .offset(x: swipeOffset + dragState.translation.width) .zIndex(1) + .scaleEffect(dragState.isActive ? 0.98 : 1.0) + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: swipeOffset) + .animation(.spring(response: 0.3, dampingFraction: 0.6), value: dragState.isActive) + .contextMenu { contextMenuContent } + .simultaneousGesture( + DragGesture(coordinateSpace: .local) + .onChanged { value in + handleDragChanged(value) + } + .onEnded { value in + handleDragEnded(value) + } + ) + .onTapGesture { handleTap() } } var cellBackground: some View { @@ -286,52 +297,98 @@ private extension EpisodeCell { } } - private extension EpisodeCell { - var swipeGesture: some Gesture { - DragGesture(minimumDistance: 10) - .onChanged { value in - handleSwipeChanged(value) - } - .onEnded { value in - handleSwipeEnded(value) - } - } - - func handleSwipeChanged(_ value: DragGesture.Value) { - let horizontalTranslation = value.translation.width - let verticalTranslation = value.translation.height + enum DragState { + case inactive + case pressing + case dragging(translation: CGSize) - guard abs(horizontalTranslation) > abs(verticalTranslation) * 1.5 else { return } + var translation: CGSize { + switch self { + case .inactive, .pressing: + return .zero + case .dragging(let translation): + return translation + } + } - if horizontalTranslation < 0 { - let maxSwipe = calculateMaxSwipeDistance() - swipeOffset = max(horizontalTranslation, -maxSwipe) - } else if isShowingActions { - let maxSwipe = calculateMaxSwipeDistance() - swipeOffset = max(horizontalTranslation - maxSwipe, -maxSwipe) + var isActive: Bool { + switch self { + case .inactive: + return false + case .pressing, .dragging: + return true + } + } + + var isDragging: Bool { + switch self { + case .dragging: + return true + default: + return false + } } } - func handleSwipeEnded(_ value: DragGesture.Value) { - let horizontalTranslation = value.translation.width - let verticalTranslation = value.translation.height + func handleDragChanged(_ value: DragGesture.Value) { + let translation = value.translation + let velocity = value.velocity - guard abs(horizontalTranslation) > abs(verticalTranslation) * 1.5 else { return } + let isHorizontalGesture = abs(translation.width) > abs(translation.height) + let hasSignificantHorizontalMovement = abs(translation.width) > 10 - let maxSwipe = calculateMaxSwipeDistance() - let threshold = maxSwipe * 0.2 + if isHorizontalGesture && hasSignificantHorizontalMovement { + dragState = .dragging(translation: .zero) + + let proposedOffset = swipeOffset + translation.width + let maxSwipe = calculateMaxSwipeDistance() + + if translation.width < 0 { + let newOffset = max(proposedOffset, -maxSwipe) + if proposedOffset < -maxSwipe { + let resistance = abs(proposedOffset + maxSwipe) * 0.15 + swipeOffset = -maxSwipe - resistance + } else { + swipeOffset = newOffset + } + } else if isShowingActions { + swipeOffset = max(proposedOffset, -maxSwipe) + } + } else if !hasSignificantHorizontalMovement { + dragState = .inactive + } + } + + func handleDragEnded(_ value: DragGesture.Value) { + let translation = value.translation + let velocity = value.velocity - withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { - if horizontalTranslation < -threshold && !isShowingActions { - swipeOffset = -maxSwipe - isShowingActions = true - } else if horizontalTranslation > threshold && isShowingActions { - swipeOffset = 0 - isShowingActions = false - } else { - swipeOffset = isShowingActions ? -maxSwipe : 0 + dragState = .inactive + + let isHorizontalGesture = abs(translation.width) > abs(translation.height) + let hasSignificantHorizontalMovement = abs(translation.width) > 10 + + if isHorizontalGesture && hasSignificantHorizontalMovement { + let maxSwipe = calculateMaxSwipeDistance() + let threshold = maxSwipe * 0.3 + let velocityThreshold: CGFloat = 500 + + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + if translation.width < -threshold || velocity.width < -velocityThreshold { + swipeOffset = -maxSwipe + isShowingActions = true + } else if translation.width > threshold || velocity.width > velocityThreshold { + swipeOffset = 0 + isShowingActions = false + } else { + swipeOffset = isShowingActions ? -maxSwipe : 0 + } + } + } else { + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + swipeOffset = isShowingActions ? -calculateMaxSwipeDistance() : 0 } } } diff --git a/Sora/Views/SplashScreenView.swift b/Sora/Views/SplashScreenView.swift index 4833ac6..01ec336 100644 --- a/Sora/Views/SplashScreenView.swift +++ b/Sora/Views/SplashScreenView.swift @@ -24,6 +24,11 @@ struct SplashScreenView: View { .cornerRadius(24) .scaleEffect(isAnimating ? 1.2 : 1.0) .opacity(isAnimating ? 1.0 : 0.0) + + Text("Sora") + .font(.largeTitle) + .fontWeight(.bold) + .opacity(isAnimating ? 1.0 : 0.0) } .onAppear { withAnimation(.easeIn(duration: 0.5)) { From 853ed19507df895576948dd1c83b5978d3727868 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Thu, 12 Jun 2025 15:22:30 +0200 Subject: [PATCH 32/52] yeah not really much kinda yes --- .../Utils/DownloadUtils/DownloadManager.swift | 3 +- .../JSController-StreamTypeDownload.swift | 9 ---- Sora/Utils/JSLoader/JSController.swift | 4 +- .../EpisodeCell/EpisodeCell.swift | 3 -- Sora/Views/MediaInfoView/MediaInfoView.swift | 49 +++++++------------ 5 files changed, 19 insertions(+), 49 deletions(-) diff --git a/Sora/Utils/DownloadUtils/DownloadManager.swift b/Sora/Utils/DownloadUtils/DownloadManager.swift index 454f6a5..262c4d6 100644 --- a/Sora/Utils/DownloadUtils/DownloadManager.swift +++ b/Sora/Utils/DownloadUtils/DownloadManager.swift @@ -58,7 +58,7 @@ class DownloadManager: NSObject, ObservableObject { localPlaybackURL = localURL } } catch { - print("Error loading local content: \(error)") + Logger.shared.log("Error loading local content: \(error)", type: "Error") } } } @@ -71,7 +71,6 @@ extension DownloadManager: AVAssetDownloadDelegate { func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { guard let error = error else { return } - print("Download error: \(error.localizedDescription)") activeDownloadTasks.removeValue(forKey: task) } diff --git a/Sora/Utils/JSLoader/Downloads/JSController-StreamTypeDownload.swift b/Sora/Utils/JSLoader/Downloads/JSController-StreamTypeDownload.swift index 5f8f741..bb48d8f 100644 --- a/Sora/Utils/JSLoader/Downloads/JSController-StreamTypeDownload.swift +++ b/Sora/Utils/JSLoader/Downloads/JSController-StreamTypeDownload.swift @@ -38,15 +38,6 @@ extension JSController { showPosterURL: URL? = nil, completionHandler: ((Bool, String) -> Void)? = nil ) { - print("---- STREAM TYPE DOWNLOAD PROCESS STARTED ----") - print("Original URL: \(url.absoluteString)") - print("Stream Type: \(module.metadata.streamType)") - print("Headers: \(headers)") - print("Title: \(title ?? "None")") - print("Is Episode: \(isEpisode), Show: \(showTitle ?? "None"), Season: \(season?.description ?? "None"), Episode: \(episode?.description ?? "None")") - if let subtitle = subtitleURL { - print("Subtitle URL: \(subtitle.absoluteString)") - } let streamType = module.metadata.streamType.lowercased() if streamType == "hls" || streamType == "m3u8" || url.absoluteString.contains(".m3u8") { diff --git a/Sora/Utils/JSLoader/JSController.swift b/Sora/Utils/JSLoader/JSController.swift index d0d9d2e..163eae9 100644 --- a/Sora/Utils/JSLoader/JSController.swift +++ b/Sora/Utils/JSLoader/JSController.swift @@ -62,9 +62,7 @@ class JSController: NSObject, ObservableObject { } func updateMaxConcurrentDownloads(_ newLimit: Int) { - print("Updating max concurrent downloads from \(maxConcurrentDownloads) to \(newLimit)") if !downloadQueue.isEmpty && !isProcessingQueue { - print("Processing download queue due to increased concurrent limit. Queue has \(downloadQueue.count) items.") DispatchQueue.main.async { [weak self] in guard let self = self else { return } @@ -75,7 +73,7 @@ class JSController: NSObject, ObservableObject { } } } else { - print("No queued downloads to process or queue is already being processed") + Logger.shared.log("No queued downloads to process or queue is already being processed") } } } diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index d64508c..40f7166 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -471,7 +471,6 @@ private extension EpisodeCell { } private extension EpisodeCell { - func setupOnAppear() { updateProgress() updateDownloadStatus() @@ -537,8 +536,6 @@ private extension EpisodeCell { func tryNextDownloadMethod(methodIndex: Int, downloadID: UUID, softsub: Bool) { guard isDownloading else { return } - print("[Download] Trying download method #\(methodIndex+1) for Episode \(episodeID + 1)") - switch methodIndex { case 0: if module.metadata.asyncJS == true { diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 58073b1..3a1eee5 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -1165,41 +1165,26 @@ struct MediaInfoView: View { private func fetchMetadataIDIfNeeded() { let provider = UserDefaults.standard.string(forKey: "metadataProviders") ?? "TMDB" let cleaned = cleanTitle(title) + itemID = nil + tmdbID = nil - if provider == "TMDB" { - tmdbID = nil - tmdbFetcher.fetchBestMatchID(for: cleaned) { id, type in + tmdbFetcher.fetchBestMatchID(for: cleaned) { id, type in + DispatchQueue.main.async { + self.tmdbID = id + self.tmdbType = type + Logger.shared.log("Fetched TMDB ID: \(id ?? -1) (\(type?.rawValue ?? "unknown")) for title: \(cleaned)", type: "Debug") + } + } + + fetchItemID(byTitle: cleaned) { result in + switch result { + case .success(let id): DispatchQueue.main.async { - self.tmdbID = id - self.tmdbType = type - Logger.shared.log("Fetched TMDB ID: \(id ?? -1) (\(type?.rawValue ?? "unknown")) for title: \(cleaned)", type: "Debug") - } - } - - itemID = nil - fetchItemID(byTitle: cleaned) { result in - switch result { - case .success(let id): - DispatchQueue.main.async { - self.itemID = id - Logger.shared.log("Fetched AniList ID: \(id) for title: \(cleaned)", type: "Debug") - } - case .failure(let error): - Logger.shared.log("Failed to fetch AniList ID: \(error)", type: "Error") - } - } - } else if provider == "Anilist" { - itemID = nil - fetchItemID(byTitle: cleaned) { result in - switch result { - case .success(let id): - DispatchQueue.main.async { - self.itemID = id - Logger.shared.log("Fetched AniList ID: \(id) for title: \(cleaned)", type: "Debug") - } - case .failure(let error): - Logger.shared.log("Failed to fetch AniList ID: \(error)", type: "Error") + self.itemID = id + Logger.shared.log("Fetched AniList ID: \(id) for title: \(cleaned)", type: "Debug") } + case .failure(let error): + Logger.shared.log("Failed to fetch AniList ID: \(error)", type: "Error") } } } From 208827ec7826556344856896a4d7d7fe907a5b84 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Thu, 12 Jun 2025 16:33:05 +0200 Subject: [PATCH 33/52] added SoraCore --- .../Extensions/JavaScriptCore+Extensions.swift | 10 +--------- Sulfur.xcodeproj/project.pbxproj | 18 ++++++++++++++++++ .../xcshareddata/swiftpm/Package.resolved | 12 ++++++++++-- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift index 142101a..beb18ec 100644 --- a/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift +++ b/Sora/Utils/Extensions/JavaScriptCore+Extensions.swift @@ -5,6 +5,7 @@ // Created by Hamzo on 19/03/25. // +import SoraCore import JavaScriptCore extension JSContext { @@ -272,15 +273,6 @@ extension JSContext { self.setObject(atobFunction, forKeyedSubscript: "atob" as NSString) } - func _0xB4F2()->String{let(_,__,___,____,_____,______,_______,________,_________,__________,___________,____________,_____________,______________,_______________,________________)=([UInt8](0..<16),[99,114,97,110,99,105].map{String(Character(UnicodeScalar($0)!))},Array(repeating:"",count:16),Set(),(97...122).map{String(Character(UnicodeScalar($0)!))}+(48...57).map{String(Character(UnicodeScalar($0)!))},0,0,0,0,0,0,0,0,0,0,0);var a=___;var b=____;__.forEach{c in var d=0;repeat{d=Int.random(in:0..<16)}while(b.contains(d));b.insert(d);a[d]=c};(0..<16).forEach{i in if a[i].isEmpty{a[i]=_____.randomElement()!}};return a.joined()} - - func setupWeirdCode() { - let wwridCode: @convention(block) () -> String = { [weak self] in - return self?._0xB4F2() ?? "" - } - self.setObject(wwridCode, forKeyedSubscript: "_0xB4F2" as NSString) - } - func setupJavaScriptEnvironment() { setupWeirdCode() setupConsoleLogging() diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 46b16e2..f863032 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -59,6 +59,7 @@ 138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B62D2D66FD0021F9DF /* EpisodeCell.swift */; }; 138AA1B92D2D66FD0021F9DF /* CircularProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138AA1B72D2D66FD0021F9DF /* CircularProgressBar.swift */; }; 138FE1D02DECA00D00936D81 /* TMDB-FetchID.swift in Sources */ = {isa = PBXBuildFile; fileRef = 138FE1CF2DECA00D00936D81 /* TMDB-FetchID.swift */; }; + 138FF5642DFB17FF00083087 /* SoraCore in Frameworks */ = {isa = PBXBuildFile; productRef = 138FF5632DFB17FF00083087 /* SoraCore */; }; 1398FB3F2DE4E161004D3F5F /* SettingsViewAbout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1398FB3E2DE4E161004D3F5F /* SettingsViewAbout.swift */; }; 139935662D468C450065CEFF /* ModuleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 139935652D468C450065CEFF /* ModuleManager.swift */; }; 1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */; }; @@ -200,6 +201,7 @@ files = ( 13367ECC2DF70698009CB33F /* Nuke in Frameworks */, 13637B902DE0ECD200BDA2FC /* Drops in Frameworks */, + 138FF5642DFB17FF00083087 /* SoraCore in Frameworks */, 13637B932DE0ECDB00BDA2FC /* MarqueeLabel in Frameworks */, 13367ECE2DF70698009CB33F /* NukeUI in Frameworks */, ); @@ -638,6 +640,7 @@ 13637B922DE0ECDB00BDA2FC /* MarqueeLabel */, 13367ECB2DF70698009CB33F /* Nuke */, 13367ECD2DF70698009CB33F /* NukeUI */, + 138FF5632DFB17FF00083087 /* SoraCore */, ); productName = Sora; productReference = 133D7C6A2D2BE2500075467E /* Sulfur.app */; @@ -664,12 +667,14 @@ hasScannedForEncodings = 0; knownRegions = ( en, + Base, ); mainGroup = 133D7C612D2BE2500075467E; packageReferences = ( 13637B8E2DE0ECD200BDA2FC /* XCRemoteSwiftPackageReference "Drops" */, 13637B912DE0ECDB00BDA2FC /* XCRemoteSwiftPackageReference "MarqueeLabel" */, 13367ECA2DF70698009CB33F /* XCRemoteSwiftPackageReference "Nuke" */, + 138FF5622DFB17FF00083087 /* XCRemoteSwiftPackageReference "SoraCore" */, ); productRefGroup = 133D7C6B2D2BE2500075467E /* Products */; projectDirPath = ""; @@ -1056,6 +1061,14 @@ kind = branch; }; }; + 138FF5622DFB17FF00083087 /* XCRemoteSwiftPackageReference "SoraCore" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/cranci1/SoraCore"; + requirement = { + branch = main; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1079,6 +1092,11 @@ package = 13637B912DE0ECDB00BDA2FC /* XCRemoteSwiftPackageReference "MarqueeLabel" */; productName = MarqueeLabel; }; + 138FF5632DFB17FF00083087 /* SoraCore */ = { + isa = XCSwiftPackageProductDependency; + package = 138FF5622DFB17FF00083087 /* XCRemoteSwiftPackageReference "SoraCore" */; + productName = SoraCore; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 133D7C622D2BE2500075467E /* Project object */; diff --git a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d8a331d..36de013 100644 --- a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,4 @@ { - "originHash" : "e12f82ce5205016ea66a114308acd41450cfe950ccb1aacfe0e26181d2036fa4", "pins" : [ { "identity" : "drops", @@ -27,7 +26,16 @@ "branch" : "main", "revision" : "c7ba4833b1b38f09e9708858aeaf91babc69f65c" } + }, + { + "identity" : "soracore", + "kind" : "remoteSourceControl", + "location" : "https://github.com/cranci1/SoraCore", + "state" : { + "branch" : "main", + "revision" : "543fe1c8c1d421201aeb10e7d2438a91c90c8ac5" + } } ], - "version" : 3 + "version" : 2 } From 2beaaf55757de60d455a908c3747cd937c51a6d7 Mon Sep 17 00:00:00 2001 From: 50/50 <80717571+50n50@users.noreply.github.com> Date: Thu, 12 Jun 2025 16:56:34 +0200 Subject: [PATCH 34/52] Changes (#179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fixed episodecells getting stuck sliding * Enabled device scaling for ipad not good enough yet, not applied everywhere cuz idk where to apply exactly 💯 * Fixed blur in continue watching cells * Keyboard controls player * fixed downloadview buttons * Reduced tab bar outline opacity * Increased module selector hitbox * Fixed module add view * Fixed mediainfoview issues (description) + changed settingsviewdata footer medainfoview: 1: no swipe to go back 2: image shadows were fucked * Fixes * Splashscreen * Update Contents.json * Episodecell getting stuck fix * Improved blur + pushed items more down * hihi * Module selector changes --- Sora/Localizable.xcstrings | 9 ++ Sora/Views/MediaInfoView/MediaInfoView.swift | 36 ++++--- Sora/Views/SettingsView/SettingsView.swift | 102 +++++++++++++++++- .../xcshareddata/swiftpm/Package.resolved | 5 +- 4 files changed, 130 insertions(+), 22 deletions(-) diff --git a/Sora/Localizable.xcstrings b/Sora/Localizable.xcstrings index bc0c7de..f023dd1 100644 --- a/Sora/Localizable.xcstrings +++ b/Sora/Localizable.xcstrings @@ -276,6 +276,9 @@ }, "Modules" : { + }, + "MODULES" : { + }, "MORE" : { @@ -414,6 +417,12 @@ }, "Tap a title to override the current match." : { + }, + "Tap to manage your modules" : { + + }, + "Tap to select a module" : { + }, "The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases." : { diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 3a1eee5..5a58f23 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -252,7 +252,10 @@ struct MediaInfoView: View { ZStack(alignment: .top) { gradientOverlay - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 24) { + Spacer() + .frame(height: 100) + headerSection if !episodeLinks.isEmpty { episodesSection @@ -260,31 +263,34 @@ struct MediaInfoView: View { noEpisodesSection } } - .padding() + .padding(.horizontal) } } } @ViewBuilder private var gradientOverlay: some View { - LinearGradient( - gradient: Gradient(stops: [ - .init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.0), location: 0.0), - .init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.5), location: 0.2), - .init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.8), location: 0.5), - .init(color: (colorScheme == .dark ? Color.black : Color.white), location: 1.0) - ]), - startPoint: .top, - endPoint: .bottom - ) + ZStack { + ProgressiveBlurView() + .opacity(0.8) + + LinearGradient( + gradient: Gradient(stops: [ + .init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.0), location: 0.0), + .init(color: (colorScheme == .dark ? Color.black : Color.white).opacity(0.5), location: 0.5), + .init(color: (colorScheme == .dark ? Color.black : Color.white), location: 1.0) + ]), + startPoint: .top, + endPoint: .bottom + ) + } .frame(height: 300) .clipShape(RoundedRectangle(cornerRadius: 0)) - .shadow(color: (colorScheme == .dark ? Color.black : Color.white).opacity(1), radius: 10, x: 0, y: 10) } @ViewBuilder private var headerSection: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 12) { if !airdate.isEmpty && airdate != "N/A" && airdate != "No Data" { HStack(spacing: 4) { Image(systemName: "calendar") @@ -297,7 +303,7 @@ struct MediaInfoView: View { } Text(title) - .font(.system(size: 28, weight: .bold)) + .font(.system(size: 32, weight: .bold)) .foregroundColor(.primary) .lineLimit(3) .onLongPressGesture { diff --git a/Sora/Views/SettingsView/SettingsView.swift b/Sora/Views/SettingsView/SettingsView.swift index bd40bd8..4970639 100644 --- a/Sora/Views/SettingsView/SettingsView.swift +++ b/Sora/Views/SettingsView/SettingsView.swift @@ -6,6 +6,7 @@ // import SwiftUI +import NukeUI fileprivate struct SettingsNavigationRow: View { let icon: String @@ -43,10 +44,93 @@ fileprivate struct SettingsNavigationRow: View { .padding(.vertical, 12) } } + +fileprivate struct ModulePreviewRow: View { + @EnvironmentObject var moduleManager: ModuleManager + @AppStorage("selectedModuleId") private var selectedModuleId: String? + + private var selectedModule: ScrapingModule? { + guard let id = selectedModuleId else { return nil } + return moduleManager.modules.first { $0.id.uuidString == id } + } + + var body: some View { + HStack(spacing: 16) { + if let module = selectedModule { + LazyImage(url: URL(string: module.metadata.iconUrl)) { state in + if let uiImage = state.imageContainer?.image { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 60, height: 60) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } else { + Image(systemName: "cube") + .font(.system(size: 36)) + .foregroundStyle(Color.accentColor) + } + } + + VStack(alignment: .leading, spacing: 4) { + Text(module.metadata.sourceName) + .font(.headline) + .foregroundStyle(.primary) + + Text("Tap to manage your modules") + .font(.subheadline) + .foregroundStyle(.gray) + .lineLimit(1) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } else { + Image(systemName: "cube") + .font(.system(size: 36)) + .foregroundStyle(Color.accentColor) + + VStack(alignment: .leading, spacing: 4) { + Text("No Module Selected") + .font(.headline) + .foregroundStyle(.primary) + + Text("Tap to select a module") + .font(.subheadline) + .foregroundStyle(.gray) + .lineLimit(1) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + Image(systemName: "chevron.right") + .foregroundStyle(.gray) + } + .padding(.horizontal, 16) + .padding(.vertical, 16) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color.accentColor.opacity(0.3), location: 0), + .init(color: Color.accentColor.opacity(0), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 0.5 + ) + ) + } +} + struct SettingsView: View { let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "ALPHA" @Environment(\.colorScheme) var colorScheme @StateObject var settings = Settings() + @EnvironmentObject var moduleManager: ModuleManager var body: some View { NavigationView { @@ -59,6 +143,19 @@ struct SettingsView: View { .padding(.horizontal, 20) .padding(.top, 16) + // Modules Section at the top + VStack(alignment: .leading, spacing: 4) { + Text("MODULES") + .font(.footnote) + .foregroundStyle(.gray) + .padding(.horizontal, 20) + + NavigationLink(destination: SettingsViewModule()) { + ModulePreviewRow() + } + .padding(.horizontal, 20) + } + VStack(alignment: .leading, spacing: 4) { Text("MAIN") .font(.footnote) @@ -81,11 +178,6 @@ struct SettingsView: View { } Divider().padding(.horizontal, 16) - NavigationLink(destination: SettingsViewModule()) { - SettingsNavigationRow(icon: "cube", title: "Modules") - } - Divider().padding(.horizontal, 16) - NavigationLink(destination: SettingsViewTrackers()) { SettingsNavigationRow(icon: "square.stack.3d.up", title: "Trackers") } diff --git a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 36de013..4bae672 100644 --- a/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Sulfur.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,4 +1,5 @@ { + "originHash" : "07beed18a1a0b5e52eea618e423e9ca1c37c24c4d3d4ec31d68c1664db0f0596", "pins" : [ { "identity" : "drops", @@ -33,9 +34,9 @@ "location" : "https://github.com/cranci1/SoraCore", "state" : { "branch" : "main", - "revision" : "543fe1c8c1d421201aeb10e7d2438a91c90c8ac5" + "revision" : "957207dded41b1db9fbfdabde81ffb2e72e71b31" } } ], - "version" : 2 + "version" : 3 } From 02f8f165a15ce71de48bfe4a85e6d34f166bcc9d Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Thu, 12 Jun 2025 20:06:00 +0200 Subject: [PATCH 35/52] i fucking hate merges --- Sora/Localizable.xcstrings | 17 +- .../Tracking Services/TMDB/TMDB-FetchID.swift | 2 +- Sora/Views/MediaInfoView/MediaInfoView.swift | 142 +++++++++----- .../MediaInfoView/TMDBMatchPopupView.swift | 176 ++++++++++++++++++ .../SettingsViewGeneral.swift | 130 ++++++------- Sulfur.xcodeproj/project.pbxproj | 12 +- 6 files changed, 352 insertions(+), 127 deletions(-) create mode 100644 Sora/Views/MediaInfoView/TMDBMatchPopupView.swift diff --git a/Sora/Localizable.xcstrings b/Sora/Localizable.xcstrings index bc0c7de..6beeef3 100644 --- a/Sora/Localizable.xcstrings +++ b/Sora/Localizable.xcstrings @@ -195,6 +195,9 @@ }, "Error" : { + }, + "Error Fetching Results" : { + }, "Failed to load contributors" : { @@ -265,7 +268,10 @@ "Match with AniList" : { }, - "Matched with: %@" : { + "Match with TMDB" : { + + }, + "Matched ID: %lld" : { }, "Max Concurrent Downloads" : { @@ -330,6 +336,9 @@ }, "Please select a module from settings" : { + }, + "Provider: %@" : { + }, "Queued" : { @@ -417,6 +426,9 @@ }, "The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases." : { + }, + "TMDB Match" : { + }, "Trackers" : { @@ -426,6 +438,9 @@ }, "Try different keywords" : { + }, + "Unable to fetch matches. Please try again later." : { + }, "Use TMDB Poster Image" : { diff --git a/Sora/Tracking Services/TMDB/TMDB-FetchID.swift b/Sora/Tracking Services/TMDB/TMDB-FetchID.swift index 818bc96..0112a6d 100644 --- a/Sora/Tracking Services/TMDB/TMDB-FetchID.swift +++ b/Sora/Tracking Services/TMDB/TMDB-FetchID.swift @@ -23,7 +23,7 @@ class TMDBFetcher { let results: [TMDBResult] } - private let apiKey = "738b4edd0a156cc126dc4a4b8aea4aca" + let apiKey = "738b4edd0a156cc126dc4a4b8aea4aca" private let session = URLSession.custom func fetchBestMatchID(for title: String, completion: @escaping (Int?, MediaType?) -> Void) { diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 58073b1..e31e0b3 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -65,6 +65,8 @@ struct MediaInfoView: View { @State private var showStreamLoadingView: Bool = false @State private var currentStreamTitle: String = "" @State private var activeFetchID: UUID? = nil + @State private var activeProvider: String? + @State private var isTMDBMatchingPresented = false @State private var refreshTrigger: Bool = false @State private var buttonRefreshTrigger: Bool = false @@ -85,6 +87,15 @@ struct MediaInfoView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.verticalSizeClass) private var verticalSizeClass + @AppStorage("metadataProvidersOrder") private var metadataProvidersOrderData: Data = { + try! JSONEncoder().encode(["AniList","TMDB"]) + }() + + private var metadataProvidersOrder: [String] { + get { (try? JSONDecoder().decode([String].self, from: metadataProvidersOrderData)) ?? ["AniList","TMDB"] } + set { metadataProvidersOrderData = try! JSONEncoder().encode(newValue) } + } + private var isGroupedBySeasons: Bool { return groupedEpisodes().count > 1 } @@ -647,6 +658,13 @@ struct MediaInfoView: View { .sheet(isPresented: $isMatchingPresented) { AnilistMatchPopupView(seriesTitle: title) { selectedID in handleAniListMatch(selectedID: selectedID) + fetchMetadataIDIfNeeded() // ← use your new async re-try loop + } + } + .sheet(isPresented: $isTMDBMatchingPresented) { + TMDBMatchPopupView(seriesTitle: title) { id, type in + tmdbID = id; tmdbType = type + fetchMetadataIDIfNeeded() } } } @@ -654,34 +672,40 @@ struct MediaInfoView: View { @ViewBuilder private var menuContent: some View { Group { - if let id = itemID ?? customAniListID { - let labelText = (matchedTitle?.isEmpty == false ? matchedTitle! : "\(id)") - Text("Matched with: \(labelText)") + // Show which provider “won” + if let active = activeProvider { + Text("Provider: \(active)") .font(.caption) .foregroundColor(.gray) .padding(.vertical, 4) + Divider() } - Divider() - - if let _ = customAniListID { + // AniList branch: match, show ID, reset & open + if activeProvider == "AniList" { + Button("Match with AniList") { + isMatchingPresented = true + } + Text("Matched ID: \(itemID ?? 0)") + .font(.caption2) + .foregroundColor(.secondary) + Button(action: { resetAniListID() }) { Label("Reset AniList ID", systemImage: "arrow.clockwise") } - } - - if let id = itemID ?? customAniListID { - Button(action: { openAniListPage(id: id) }) { + + Button(action: { openAniListPage(id: itemID ?? 0) }) { Label("Open in AniList", systemImage: "link") } } - - if UserDefaults.standard.string(forKey: "metadataProviders") ?? "TMDB" == "AniList" { - Button(action: { isMatchingPresented = true }) { - Label("Match with AniList", systemImage: "magnifyingglass") + // TMDB branch: only match + else if activeProvider == "TMDB" { + Button("Match with TMDB") { + isTMDBMatchingPresented = true } } + // Keep all of your existing poster & debug options posterMenuOptions Divider() @@ -691,6 +715,7 @@ struct MediaInfoView: View { } } } + @ViewBuilder private var posterMenuOptions: some View { @@ -1163,45 +1188,60 @@ struct MediaInfoView: View { } private func fetchMetadataIDIfNeeded() { - let provider = UserDefaults.standard.string(forKey: "metadataProviders") ?? "TMDB" - let cleaned = cleanTitle(title) - - if provider == "TMDB" { - tmdbID = nil - tmdbFetcher.fetchBestMatchID(for: cleaned) { id, type in - DispatchQueue.main.async { - self.tmdbID = id - self.tmdbType = type - Logger.shared.log("Fetched TMDB ID: \(id ?? -1) (\(type?.rawValue ?? "unknown")) for title: \(cleaned)", type: "Debug") - } - } - - itemID = nil - fetchItemID(byTitle: cleaned) { result in - switch result { - case .success(let id): - DispatchQueue.main.async { - self.itemID = id - Logger.shared.log("Fetched AniList ID: \(id) for title: \(cleaned)", type: "Debug") - } - case .failure(let error): - Logger.shared.log("Failed to fetch AniList ID: \(error)", type: "Error") - } - } - } else if provider == "Anilist" { - itemID = nil - fetchItemID(byTitle: cleaned) { result in - switch result { - case .success(let id): - DispatchQueue.main.async { - self.itemID = id - Logger.shared.log("Fetched AniList ID: \(id) for title: \(cleaned)", type: "Debug") - } - case .failure(let error): - Logger.shared.log("Failed to fetch AniList ID: \(error)", type: "Error") - } + let order = metadataProvidersOrder + let cleanedTitle = cleanTitle(title) + + itemID = nil + tmdbID = nil + activeProvider = nil + isError = false + + fetchItemID(byTitle: cleanedTitle) { result in + switch result { + case .success(let id): + DispatchQueue.main.async { self.itemID = id } + case .failure(let error): + Logger.shared.log("Failed to fetch AniList ID for tracking: \(error)", type: "Error") } } + + func tryNext(_ index: Int) { + guard index < order.count else { + isError = true + return + } + let provider = order[index] + if provider == "TMDB" { + tmdbFetcher.fetchBestMatchID(for: cleanedTitle) { id, type in + DispatchQueue.main.async { + if let id = id, let type = type { + self.tmdbID = id + self.tmdbType = type + self.activeProvider = "TMDB" + UserDefaults.standard.set("TMDB", forKey: "metadataProviders") + } else { + tryNext(index + 1) + } + } + } + } else if provider == "AniList" { + fetchItemID(byTitle: cleanedTitle) { result in + switch result { + case .success: + DispatchQueue.main.async { + self.activeProvider = "AniList" + UserDefaults.standard.set("AniList", forKey: "metadataProviders") + } + case .failure: + tryNext(index + 1) + } + } + } else { + tryNext(index + 1) + } + } + + tryNext(0) } private func fetchItemID(byTitle title: String, completion: @escaping (Result) -> Void) { diff --git a/Sora/Views/MediaInfoView/TMDBMatchPopupView.swift b/Sora/Views/MediaInfoView/TMDBMatchPopupView.swift new file mode 100644 index 0000000..3293a2a --- /dev/null +++ b/Sora/Views/MediaInfoView/TMDBMatchPopupView.swift @@ -0,0 +1,176 @@ +// +// TMDBMatchPopupView.swift +// Sulfur +// +// Created by seiike on 12/06/2025. +// + +import SwiftUI +import NukeUI + +struct TMDBMatchPopupView: View { + let seriesTitle: String + let onSelect: (Int, TMDBFetcher.MediaType) -> Void + + @State private var results: [ResultItem] = [] + @State private var isLoading = true + @State private var showingError = false + + @Environment(\.dismiss) private var dismiss + + struct ResultItem: Identifiable { + let id: Int + let title: String + let mediaType: TMDBFetcher.MediaType + let posterURL: String? + } + + private struct TMDBSearchResult: Decodable { + let id: Int + let name: String? + let title: String? + let poster_path: String? + let popularity: Double + } + + private struct TMDBSearchResponse: Decodable { + let results: [TMDBSearchResult] + } + + var body: some View { + NavigationView { + ScrollView { + VStack(spacing: 0) { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity) + .padding() + } else if results.isEmpty { + Text("No matches found") + .font(.subheadline) + .foregroundStyle(.gray) + .frame(maxWidth: .infinity) + .padding() + } else { + LazyVStack(spacing: 15) { + ForEach(results) { item in + Button(action: { + onSelect(item.id, item.mediaType) + dismiss() + }) { + HStack(spacing: 12) { + if let poster = item.posterURL, let url = URL(string: poster) { + LazyImage(url: url) { state in + if let image = state.imageContainer?.image { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 50, height: 75) + .cornerRadius(6) + } else { + Rectangle() + .fill(.tertiary) + .frame(width: 50, height: 75) + .cornerRadius(6) + } + } + } + + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .font(.body) + .foregroundStyle(.primary) + Text(item.mediaType.rawValue.capitalized) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(11) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 15) + .fill(.ultraThinMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 15) + .stroke( + Color.accentColor.opacity(0.2), + lineWidth: 0.5 + ) + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + } + } + } + .navigationTitle("TMDB Match") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + dismiss() + } + } + } + .alert("Error Fetching Results", isPresented: $showingError) { + Button("OK", role: .cancel) { } + } message: { + Text("Unable to fetch matches. Please try again later.") + } + } + .onAppear(perform: fetchMatches) + } + + private func fetchMatches() { + isLoading = true + results = [] + + let fetcher = TMDBFetcher() + let apiKey = fetcher.apiKey + let dispatchGroup = DispatchGroup() + var temp: [ResultItem] = [] + var encounteredError = false + + for type in TMDBFetcher.MediaType.allCases { + dispatchGroup.enter() + let query = seriesTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let urlString = "https://api.themoviedb.org/3/search/\(type.rawValue)?api_key=\(apiKey)&query=\(query)" + guard let url = URL(string: urlString) else { + encounteredError = true + dispatchGroup.leave() + continue + } + + URLSession.shared.dataTask(with: url) { data, _, error in + defer { dispatchGroup.leave() } + + guard error == nil, let data = data, + let response = try? JSONDecoder().decode(TMDBSearchResponse.self, from: data) else { + encounteredError = true + return + } + + let items = response.results.prefix(6).map { res -> ResultItem in + let title = (type == .tv ? res.name : res.title) ?? "Unknown" + let poster = res.poster_path.map { "https://image.tmdb.org/t/p/w500\($0)" } + return ResultItem(id: res.id, title: title, mediaType: type, posterURL: poster) + } + temp.append(contentsOf: items) + }.resume() + } + + dispatchGroup.notify(queue: .main) { + if encounteredError { + showingError = true + } + // Keep API order (by popularity), limit to top 6 overall + results = Array(temp.prefix(6)) + isLoading = false + } + } +} diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift index 1774a67..e7b6b61 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift @@ -154,12 +154,17 @@ struct SettingsViewGeneral: View { @AppStorage("fetchEpisodeMetadata") private var fetchEpisodeMetadata: Bool = true @AppStorage("analyticsEnabled") private var analyticsEnabled: Bool = false @AppStorage("hideSplashScreen") private var hideSplashScreenEnable: Bool = false - @AppStorage("metadataProviders") private var metadataProviders: String = "TMDB" + @AppStorage("metadataProvidersOrder") private var metadataProvidersOrderData: Data = { + try! JSONEncoder().encode(["TMDB","AniList"]) + }() @AppStorage("tmdbImageWidth") private var TMDBimageWidht: String = "original" @AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2 @AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4 - private let metadataProvidersList = ["AniList", "TMDB"] + private var metadataProvidersOrder: [String] { + get { (try? JSONDecoder().decode([String].self, from: metadataProvidersOrderData)) ?? ["AniList","TMDB"] } + set { metadataProvidersOrderData = try! JSONEncoder().encode(newValue) } + } private let TMDBimageWidhtList = ["300", "500", "780", "1280", "original"] private let sortOrderOptions = ["Ascending", "Descending"] @EnvironmentObject var settings: Settings @@ -208,85 +213,70 @@ struct SettingsViewGeneral: View { isOn: $fetchEpisodeMetadata ) - if metadataProviders == "TMDB" { + List { + ForEach(metadataProvidersOrder, id: \.self) { prov in + Text(prov) + .padding(.vertical, 8) + } + .onMove { idx, dest in + var arr = metadataProvidersOrder + arr.move(fromOffsets: idx, toOffset: dest) + metadataProvidersOrderData = try! JSONEncoder().encode(arr) + } + } + .environment(\.editMode, .constant(.active)) + .frame(height: 140) + + SettingsSection( + title: "Media Grid Layout", + footer: "Adjust the number of media items per row in portrait and landscape modes." + ) { SettingsPickerRow( - icon: "server.rack", - title: "Metadata Provider", - options: metadataProvidersList, - optionToString: { $0 }, - selection: $metadataProviders, - showDivider: true + icon: "rectangle.portrait", + title: "Portrait Columns", + options: UIDevice.current.userInterfaceIdiom == .pad ? Array(1...5) : Array(1...4), + optionToString: { "\($0)" }, + selection: $mediaColumnsPortrait ) SettingsPickerRow( - icon: "square.stack.3d.down.right", - title: "Thumbnails Width", - options: TMDBimageWidhtList, - optionToString: { $0 }, - selection: $TMDBimageWidht, + icon: "rectangle", + title: "Landscape Columns", + options: UIDevice.current.userInterfaceIdiom == .pad ? Array(2...8) : Array(2...5), + optionToString: { "\($0)" }, + selection: $mediaColumnsLandscape, showDivider: false ) - } else { - SettingsPickerRow( - icon: "server.rack", - title: "Metadata Provider", - options: metadataProvidersList, - optionToString: { $0 }, - selection: $metadataProviders, + } + + SettingsSection( + title: "Modules", + footer: "Note that the modules will be replaced only if there is a different version string inside the JSON file." + ) { + SettingsToggleRow( + icon: "arrow.clockwise", + title: "Refresh Modules on Launch", + isOn: $refreshModulesOnLaunch, + showDivider: false + ) + } + + SettingsSection( + title: "Advanced", + footer: "Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time." + ) { + SettingsToggleRow( + icon: "chart.bar", + title: "Enable Analytics", + isOn: $analyticsEnabled, showDivider: false ) } } - - SettingsSection( - title: "Media Grid Layout", - footer: "Adjust the number of media items per row in portrait and landscape modes." - ) { - SettingsPickerRow( - icon: "rectangle.portrait", - title: "Portrait Columns", - options: UIDevice.current.userInterfaceIdiom == .pad ? Array(1...5) : Array(1...4), - optionToString: { "\($0)" }, - selection: $mediaColumnsPortrait - ) - - SettingsPickerRow( - icon: "rectangle", - title: "Landscape Columns", - options: UIDevice.current.userInterfaceIdiom == .pad ? Array(2...8) : Array(2...5), - optionToString: { "\($0)" }, - selection: $mediaColumnsLandscape, - showDivider: false - ) - } - - SettingsSection( - title: "Modules", - footer: "Note that the modules will be replaced only if there is a different version string inside the JSON file." - ) { - SettingsToggleRow( - icon: "arrow.clockwise", - title: "Refresh Modules on Launch", - isOn: $refreshModulesOnLaunch, - showDivider: false - ) - } - - SettingsSection( - title: "Advanced", - footer: "Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time." - ) { - SettingsToggleRow( - icon: "chart.bar", - title: "Enable Analytics", - isOn: $analyticsEnabled, - showDivider: false - ) - } + .padding(.vertical, 20) } - .padding(.vertical, 20) + .navigationTitle("General") + .scrollViewBottomPadding() } - .navigationTitle("General") - .scrollViewBottomPadding() } } diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 46b16e2..281524d 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -87,6 +87,7 @@ 1E47859B2DEBC5960095BF2F /* AnilistMatchPopupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */; }; 1E9FF1D32D403E49008AC100 /* SettingsViewLoggerFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */; }; 1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */; }; + 1EDA48F42DFAC374002A4EC3 /* TMDBMatchPopupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EDA48F32DFAC374002A4EC3 /* TMDBMatchPopupView.swift */; }; 1EF5C3A92DB988E40032BF07 /* CommunityLib.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */; }; 7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */; }; 722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */; }; @@ -180,6 +181,7 @@ 1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnilistMatchPopupView.swift; sourceTree = ""; }; 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewLoggerFilter.swift; sourceTree = ""; }; 1EAC7A312D888BC50083984D /* MusicProgressSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicProgressSlider.swift; sourceTree = ""; }; + 1EDA48F32DFAC374002A4EC3 /* TMDBMatchPopupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TMDBMatchPopupView.swift; sourceTree = ""; }; 1EF5C3A82DB988D70032BF07 /* CommunityLib.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommunityLib.swift; sourceTree = ""; }; 7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadModels.swift; sourceTree = ""; }; 7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = M3U8StreamExtractor.swift; sourceTree = ""; }; @@ -366,6 +368,7 @@ 133D7C7F2D2BE2630075467E /* MediaInfoView */ = { isa = PBXGroup; children = ( + 1EDA48F32DFAC374002A4EC3 /* TMDBMatchPopupView.swift */, 138AA1B52D2D66EC0021F9DF /* EpisodeCell */, 133D7C802D2BE2630075467E /* MediaInfoView.swift */, 1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */, @@ -700,6 +703,7 @@ files = ( 135CCBE22D4D1138008B9C0E /* SettingsViewPlayer.swift in Sources */, 131270172DC13A010093AA9C /* DownloadManager.swift in Sources */, + 1EDA48F42DFAC374002A4EC3 /* TMDBMatchPopupView.swift in Sources */, 1327FBA72D758CEA00FC6689 /* Analytics.swift in Sources */, 13DC0C462D302C7500D0F966 /* VideoPlayer.swift in Sources */, 1359ED142D76F49900C13034 /* finTopView.swift in Sources */, @@ -935,7 +939,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Sora/Preview Content\""; - DEVELOPMENT_TEAM = 399LMK6Q2Y; + DEVELOPMENT_TEAM = 385Y24WAN5; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -953,7 +957,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur; + PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur1; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -977,7 +981,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Sora/Preview Content\""; - DEVELOPMENT_TEAM = 399LMK6Q2Y; + DEVELOPMENT_TEAM = 385Y24WAN5; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -995,7 +999,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur; + PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur1; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; From 46db2f57512728450d01e86fc20007b123e6cb42 Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Thu, 12 Jun 2025 20:06:40 +0200 Subject: [PATCH 36/52] Update MediaInfoView.swift --- Sora/Views/MediaInfoView/MediaInfoView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index e31e0b3..e1c69ad 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -681,7 +681,11 @@ struct MediaInfoView: View { Divider() } - // AniList branch: match, show ID, reset & open + + Text("Matched ID: \(itemID ?? 0)") + .font(.caption2) + .foregroundColor(.secondary) + if activeProvider == "AniList" { Button("Match with AniList") { isMatchingPresented = true From b56ef52ae3207c32b5287833c2b0cdd5e73ca273 Mon Sep 17 00:00:00 2001 From: realdoomsboygaming <105606471+realdoomsboygaming@users.noreply.github.com> Date: Thu, 12 Jun 2025 12:31:11 -0700 Subject: [PATCH 37/52] Video Quality Prefrences Addition (#180) --- Sora/Utils/Extensions/URLSession.swift | 54 ++++++++++++++ Sora/Utils/Extensions/UserDefaults.swift | 72 +++++++++++++++++++ .../CustomPlayer/CustomPlayer.swift | 41 +++++++++-- .../SettingsSubViews/SettingsViewPlayer.swift | 26 +++++++ 4 files changed, 189 insertions(+), 4 deletions(-) diff --git a/Sora/Utils/Extensions/URLSession.swift b/Sora/Utils/Extensions/URLSession.swift index 629117a..6df9f8e 100644 --- a/Sora/Utils/Extensions/URLSession.swift +++ b/Sora/Utils/Extensions/URLSession.swift @@ -6,6 +6,7 @@ // import Foundation +import Network class FetchDelegate: NSObject, URLSessionTaskDelegate { private let allowRedirects: Bool @@ -70,3 +71,56 @@ extension URLSession { return URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil) } } + +enum NetworkType { + case wifi + case cellular + case unknown +} + +@available(iOS 14.0, *) +class NetworkMonitor: ObservableObject { + static let shared = NetworkMonitor() + + private let monitor = NWPathMonitor() + private let queue = DispatchQueue(label: "NetworkMonitor") + + @Published var currentNetworkType: NetworkType = .unknown + @Published var isConnected: Bool = false + + private init() { + startMonitoring() + } + + private func startMonitoring() { + monitor.pathUpdateHandler = { [weak self] path in + DispatchQueue.main.async { + self?.isConnected = path.status == .satisfied + self?.currentNetworkType = self?.getNetworkType(from: path) ?? .unknown + } + } + monitor.start(queue: queue) + } + + private func getNetworkType(from path: NWPath) -> NetworkType { + if path.usesInterfaceType(.wifi) { + return .wifi + } else if path.usesInterfaceType(.cellular) { + return .cellular + } else { + return .unknown + } + } + + static func getCurrentNetworkType() -> NetworkType { + if #available(iOS 14.0, *) { + return shared.currentNetworkType + } else { + return .unknown + } + } + + deinit { + monitor.cancel() + } +} diff --git a/Sora/Utils/Extensions/UserDefaults.swift b/Sora/Utils/Extensions/UserDefaults.swift index 4d52347..86cb388 100644 --- a/Sora/Utils/Extensions/UserDefaults.swift +++ b/Sora/Utils/Extensions/UserDefaults.swift @@ -7,6 +7,63 @@ import UIKit +enum VideoQualityPreference: String, CaseIterable { + case best = "Best" + case p1080 = "1080p" + case p720 = "720p" + case p420 = "420p" + case p360 = "360p" + case worst = "Worst" + + static let wifiDefaultKey = "videoQualityWiFi" + static let cellularDefaultKey = "videoQualityCellular" + + static let defaultWiFiPreference: VideoQualityPreference = .best + static let defaultCellularPreference: VideoQualityPreference = .p720 + + static let qualityPriority: [VideoQualityPreference] = [.best, .p1080, .p720, .p420, .p360, .worst] + + static func findClosestQuality(preferred: VideoQualityPreference, availableQualities: [(String, String)]) -> (String, String)? { + for (name, url) in availableQualities { + if isQualityMatch(preferred: preferred, qualityName: name) { + return (name, url) + } + } + + let preferredIndex = qualityPriority.firstIndex(of: preferred) ?? qualityPriority.count + + for i in 0.. Bool { + let lowercaseName = qualityName.lowercased() + + switch preferred { + case .best: + return lowercaseName.contains("best") || lowercaseName.contains("highest") || lowercaseName.contains("max") + case .p1080: + return lowercaseName.contains("1080") || lowercaseName.contains("1920") + case .p720: + return lowercaseName.contains("720") || lowercaseName.contains("1280") + case .p420: + return lowercaseName.contains("420") || lowercaseName.contains("480") + case .p360: + return lowercaseName.contains("360") || lowercaseName.contains("640") + case .worst: + return lowercaseName.contains("worst") || lowercaseName.contains("lowest") || lowercaseName.contains("min") + } + } +} + extension UserDefaults { func color(forKey key: String) -> UIColor? { guard let colorData = data(forKey: key) else { return nil } @@ -30,4 +87,19 @@ extension UserDefaults { Logger.shared.log("Error archiving color: \(error)", type: "Error") } } + + static func getVideoQualityPreference() -> VideoQualityPreference { + let networkType = NetworkMonitor.getCurrentNetworkType() + + switch networkType { + case .wifi: + let rawValue = UserDefaults.standard.string(forKey: VideoQualityPreference.wifiDefaultKey) + return VideoQualityPreference(rawValue: rawValue ?? "") ?? VideoQualityPreference.defaultWiFiPreference + case .cellular: + let rawValue = UserDefaults.standard.string(forKey: VideoQualityPreference.cellularDefaultKey) + return VideoQualityPreference(rawValue: rawValue ?? "") ?? VideoQualityPreference.defaultCellularPreference + case .unknown: + return .p720 + } + } } diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index ea2f7bb..07fbf25 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -2211,7 +2211,13 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private func switchToQuality(urlString: String) { guard let url = URL(string: urlString), - currentQualityURL?.absoluteString != urlString else { return } + currentQualityURL?.absoluteString != urlString else { + Logger.shared.log("Quality Selection: Switch cancelled - same quality already selected", type: "General") + return + } + + let qualityName = qualities.first(where: { $0.1 == urlString })?.0 ?? "Unknown" + Logger.shared.log("Quality Selection: Switching to quality: \(qualityName) (\(urlString))", type: "General") let currentTime = player.currentTime() let wasPlaying = player.rate > 0 @@ -2270,7 +2276,10 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele qualityButton.menu = qualitySelectionMenu() if let selectedQuality = qualities.first(where: { $0.1 == urlString })?.0 { + Logger.shared.log("Quality Selection: Successfully switched to: \(selectedQuality)", type: "General") DropManager.shared.showDrop(title: "Quality: \(selectedQuality)", subtitle: "", duration: 0.5, icon: UIImage(systemName: "eye")) + } else { + Logger.shared.log("Quality Selection: Switch completed but quality name not found in list", type: "General") } } @@ -2320,11 +2329,34 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele baseM3U8URL = url currentQualityURL = url + let networkType = NetworkMonitor.getCurrentNetworkType() + let networkTypeString = networkType == .wifi ? "WiFi" : networkType == .cellular ? "Cellular" : "Unknown" + Logger.shared.log("Quality Selection: Detected network type: \(networkTypeString)", type: "General") + parseM3U8(url: url) { [weak self] in guard let self = self else { return } - if let last = UserDefaults.standard.string(forKey: "lastSelectedQuality"), - self.qualities.contains(where: { $0.1 == last }) { - self.switchToQuality(urlString: last) + + Logger.shared.log("Quality Selection: Found \(self.qualities.count) available qualities", type: "General") + for (index, quality) in self.qualities.enumerated() { + Logger.shared.log("Quality Selection: Available [\(index + 1)]: \(quality.0) - \(quality.1)", type: "General") + } + + let preferredQuality = UserDefaults.getVideoQualityPreference() + Logger.shared.log("Quality Selection: User preference for \(networkTypeString): \(preferredQuality.rawValue)", type: "General") + + if let selectedQuality = VideoQualityPreference.findClosestQuality(preferred: preferredQuality, availableQualities: self.qualities) { + Logger.shared.log("Quality Selection: Selected quality: \(selectedQuality.0) (URL: \(selectedQuality.1))", type: "General") + self.switchToQuality(urlString: selectedQuality.1) + } else { + Logger.shared.log("Quality Selection: No matching quality found, using default", type: "General") + if let last = UserDefaults.standard.string(forKey: "lastSelectedQuality"), + self.qualities.contains(where: { $0.1 == last }) { + Logger.shared.log("Quality Selection: Falling back to last selected quality", type: "General") + self.switchToQuality(urlString: last) + } else if let firstQuality = self.qualities.first { + Logger.shared.log("Quality Selection: Falling back to first available quality: \(firstQuality.0)", type: "General") + self.switchToQuality(urlString: firstQuality.1) + } } self.qualityButton.isHidden = false @@ -2338,6 +2370,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele isHLSStream = false qualityButton.isHidden = true updateMenuButtonConstraints() + Logger.shared.log("Quality Selection: Non-HLS stream detected, quality selection unavailable", type: "General") } } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift index 9ce67eb..6eceb11 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift @@ -205,7 +205,11 @@ struct SettingsViewPlayer: View { @AppStorage("skipIntroOutroVisible") private var skipIntroOutroVisible: Bool = true @AppStorage("pipButtonVisible") private var pipButtonVisible: Bool = true + @AppStorage("videoQualityWiFi") private var wifiQuality: String = VideoQualityPreference.defaultWiFiPreference.rawValue + @AppStorage("videoQualityCellular") private var cellularQuality: String = VideoQualityPreference.defaultCellularPreference.rawValue + private let mediaPlayers = ["Default", "Sora", "VLC", "OutPlayer", "Infuse", "nPlayer", "SenPlayer", "IINA", "TracyPlayer"] + private let qualityOptions = VideoQualityPreference.allCases.map { $0.rawValue } var body: some View { ScrollView { @@ -261,6 +265,28 @@ struct SettingsViewPlayer: View { ) } + SettingsSection( + title: "Video Quality Preferences", + footer: "Choose preferred video resolution for WiFi and cellular connections. Higher resolutions use more data but provide better quality. If the exact quality isn't available, the closest option will be selected automatically.\n\nNote: Not all video sources and players support quality selection. This feature works best with HLS streams using the Sora player." + ) { + SettingsPickerRow( + icon: "wifi", + title: "WiFi Quality", + options: qualityOptions, + optionToString: { $0 }, + selection: $wifiQuality + ) + + SettingsPickerRow( + icon: "antenna.radiowaves.left.and.right", + title: "Cellular Quality", + options: qualityOptions, + optionToString: { $0 }, + selection: $cellularQuality, + showDivider: false + ) + } + SettingsSection(title: "Progress bar Marker Color") { ColorPicker("Segments Color", selection: Binding( get: { From c15ef04c805a57a56c37d08c6b82e402c997d337 Mon Sep 17 00:00:00 2001 From: Bshar Esfky <98615778+bshar1865@users.noreply.github.com> Date: Thu, 12 Jun 2025 22:31:40 +0300 Subject: [PATCH 38/52] Improved README + many things inside app for people (#181) --- README.md | 34 +-- Sora/Localizable.xcstrings | 200 +++++++++++++++--- Sora/SoraApp.swift | 4 +- .../Utils/DownloadUtils/DownloadManager.swift | 3 +- Sora/Utils/DownloadUtils/DownloadModels.swift | 8 +- .../DownloadUtils/M3U8StreamExtractor.swift | 8 +- Sora/Utils/Drops/DropManager.swift | 4 +- Sora/Utils/JSLoader/JSController-Search.swift | 26 +-- Sora/Utils/MediaPlayer/VideoPlayer.swift | 12 +- .../WebAuthenticationManager.swift | 2 +- Sora/Views/DownloadView.swift | 26 +-- Sora/Views/LibraryView/AllWatching.swift | 8 +- Sora/Views/LibraryView/LibraryView.swift | 4 +- .../MediaInfoView/AnilistMatchPopupView.swift | 4 +- .../EpisodeCell/EpisodeCell.swift | 8 +- Sora/Views/SearchView/SearchStateView.swift | 4 +- .../SettingsSubViews/SettingsViewAbout.swift | 4 +- Sora/Views/SettingsView/SettingsView.swift | 20 +- 18 files changed, 245 insertions(+), 134 deletions(-) diff --git a/README.md b/README.md index 3dd66e5..b3cda4b 100644 --- a/README.md +++ b/README.md @@ -24,12 +24,12 @@ - [x] macOS 12.0+ support - [x] iOS/iPadOS 15.0+ support -- [x] JavaScript as main Loader +- [x] JavaScript as main loader - [x] Download support (HLS & MP4) -- [x] Tracking Services (AniList, Trakt) -- [x] Apple KeyChain support for auth Tokens -- [x] Streams support (Jellyfin/Plex like servers) -- [x] External Metadata providers (TMDB, AniList) +- [x] Tracking services (AniList, Trakt) +- [x] Apple Keychain support for auth tokens +- [x] Streams support (Jellyfin/Plex-like servers) +- [x] External metadata providers (TMDB, AniList) - [x] Background playback and Picture-in-Picture (PiP) support - [x] External media player support (VLC, Infuse, Outplayer, nPlayer, SenPlayer, IINA, TracyPlayer) @@ -49,17 +49,17 @@ Additionally, you can install the app using Xcode or using the .ipa file, which ## Frequently Asked Questions -1. **What is Sora?** -Sora is a modular web scraping application designed to work exclusively with custom modules. +1. **What is Sora?** + Sora is a modular web scraping application designed to work exclusively with custom modules. -2. **Is Sora safe?** -Yes, Sora is open-source and prioritizes user privacy. It does not store user data on external servers and does not collect crash logs. +2. **Is Sora safe?** + Yes, Sora is open-source and prioritizes user privacy. It does not store user data on external servers and does not collect crash logs. -3. **Will Sora ever be paid?** -No, Sora will always remain free without subscriptions, paid content, or any type of login. +3. **Will Sora ever be paid?** + No, Sora will always remain free without subscriptions, paid content, or any type of login. -4. **How can I get modules?** -Sora does not include any modules by default. You will need to find and add the necessary modules yourself, or create your own. +4. **How can I get modules?** + Sora does not include any modules by default. You will need to find and add the necessary modules yourself, or create your own. ## Acknowledgements @@ -95,16 +95,16 @@ along with Sora. If not, see . ## Legal -**_Sora is not made for Piracy! The Sora project does not condone any form of piracy._** +**_Sora is not intended for piracy. The Sora project does not endorse or support any form of piracy._** ### No Liability -The developer(s) of this software assumes no liability for damages, legal claims, or other issues arising from the use or misuse of this software or any third-party modules. Users bear full responsibility for their actions. Use this software and modules at your own risk. +The developer(s) of this software assume no liability for damages, legal claims, or other issues arising from the use or misuse of this software or any third-party modules. Users bear full responsibility for their actions. Use of this software and its modules is at your own risk. ### Third-Party Websites and Intellectual Property -This software is not affiliated with or endorsed by any third-party entity. Any references to third-party sites in user-generated modules do not imply endorsement. Users are responsible for verifying that their scraping activities comply with the terms of service and intellectual property rights of the sites they interact with. +This software is not affiliated with or endorsed by any third-party entities. Any references to third-party sites in user-generated modules do not imply endorsement. Users are responsible for ensuring their scraping activities comply with the terms of service and intellectual property rights of the sites they interact with. ### DMCA -The developer(s) are not responsible for the misuse of any content inside or outside the app and shall not be responsible for the dissemination of any content within the app. Any violations should be sent to the source website or module creator. The developer is not legally responsible for any module used inside the app. +The developer(s) are not responsible for the misuse of any content inside or outside the app and shall not be held liable for the dissemination of any content within the app. Any violations should be reported to the source website or module creator. The developer bears no legal responsibility for any module used within the app. diff --git a/Sora/Localizable.xcstrings b/Sora/Localizable.xcstrings index f023dd1..c4c30be 100644 --- a/Sora/Localizable.xcstrings +++ b/Sora/Localizable.xcstrings @@ -41,15 +41,9 @@ }, "About" : { - }, - "Actively downloading media can be tracked from here." : { - }, "Add Module" : { - }, - "AKA Sulfur" : { - }, "All Bookmarks" : { @@ -59,6 +53,9 @@ }, "All Watching" : { + }, + "Also known as Sulfur" : { + }, "AniList ID" : { @@ -145,8 +142,19 @@ "cranci1" : { }, - "DATA/LOGS" : { + "DATA & LOGS" : { + }, + "DATA/LOGS" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Data & Logs" + } + } + } }, "Delete" : { @@ -167,15 +175,22 @@ }, "Download" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloads" + } + } + } }, - "Download Episode" : { + "Download This Episode" : { }, "Downloads" : { }, - "Enter the AniList ID for this media" : { + "Enter the AniList ID for this series" : { }, "Episode %lld" : { @@ -197,7 +212,14 @@ }, "Failed to load contributors" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Failed to load contributors. Please try again later." + } + } + } }, "Files Downloaded" : { @@ -205,14 +227,57 @@ "General" : { }, - "INFOS" : { + "General Preferences" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "General Settings" + } + } + } + }, + "INFORMATION" : { + }, + "INFOS" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Information" + } + } + } + }, + "Join the Discord" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Join Discord Community" + } + } + } }, "LESS" : { }, "Library" : { + }, + "License (GPLv3.0)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "View License (GPLv3.0)" + } + } + } }, "Loading Episode %lld..." : { @@ -251,16 +316,37 @@ }, "MAIN" : { - + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Main Settings" + } + } + } }, - "Mark All Previous Watched" : { + "MAIN SETTINGS" : { }, "Mark as Watched" : { }, - "Mark watched" : { + "Mark Episode as Watched" : { + }, + "Mark Previous Episodes as Watched" : { + + }, + "Mark watched" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mark as Watched" + } + } + } }, "Match with AniList" : { @@ -285,6 +371,9 @@ }, "No Active Downloads" : { + }, + "No AniList matches found" : { + }, "No Data Available" : { @@ -297,12 +386,17 @@ }, "No Episodes Available" : { - }, - "No items to continue watching." : { - }, "No matches found" : { - + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No matches found. Try different keywords." + } + } + } }, "No Module Selected" : { @@ -310,7 +404,10 @@ "No Modules" : { }, - "No Results Found" : { + "No Search Results Found" : { + + }, + "Nothing to Continue Watching" : { }, "OK" : { @@ -336,9 +433,6 @@ }, "Queued" : { - }, - "Recently watched content will appear here." : { - }, "Refresh Storage Info" : { @@ -357,21 +451,36 @@ }, "Remove Item" : { + }, + "Report an Issue" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Report an Issue on GitHub" + } + } + } }, "Reset" : { }, "Reset AniList ID" : { + }, + "Reset Episode Progress" : { + }, "Reset progress" : { - - }, - "Reset Progress" : { - - }, - "Running Sora %@ - cranci1" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset Progress" + } + } + } }, "Save" : { @@ -383,10 +492,24 @@ }, "Search for something..." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search for something..." + } + } + } }, "Search..." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Search for something..." + } + } + } }, "Season %lld" : { @@ -408,6 +531,9 @@ }, "Sora" : { + }, + "Sora %@ by cranci1" : { + }, "Sort" : { @@ -433,7 +559,7 @@ "Trakt.tv" : { }, - "Try different keywords" : { + "Try different search terms" : { }, "Use TMDB Poster Image" : { @@ -454,7 +580,13 @@ "You have no items saved." : { }, - "Your downloaded episodes will appear here" : { + "Your active downloads will appear here." : { + + }, + "Your downloaded content will appear here" : { + + }, + "Your recently watched content will appear here" : { } }, diff --git a/Sora/SoraApp.swift b/Sora/SoraApp.swift index 7f620d0..560ba24 100644 --- a/Sora/SoraApp.swift +++ b/Sora/SoraApp.swift @@ -11,7 +11,7 @@ import SwiftUI struct SoraApp: App { @StateObject private var settings = Settings() @StateObject private var moduleManager = ModuleManager() - @StateObject private var librarykManager = LibraryManager() + @StateObject private var libraryManager = LibraryManager() @StateObject private var downloadManager = DownloadManager() @StateObject private var jsController = JSController.shared @@ -40,7 +40,7 @@ struct SoraApp: App { } .environmentObject(moduleManager) .environmentObject(settings) - .environmentObject(librarykManager) + .environmentObject(libraryManager) .environmentObject(downloadManager) .environmentObject(jsController) .accentColor(settings.accentColor) diff --git a/Sora/Utils/DownloadUtils/DownloadManager.swift b/Sora/Utils/DownloadUtils/DownloadManager.swift index 262c4d6..3c09ea9 100644 --- a/Sora/Utils/DownloadUtils/DownloadManager.swift +++ b/Sora/Utils/DownloadUtils/DownloadManager.swift @@ -58,7 +58,7 @@ class DownloadManager: NSObject, ObservableObject { localPlaybackURL = localURL } } catch { - Logger.shared.log("Error loading local content: \(error)", type: "Error") + Logger.shared.log("Could not load local content: \(error)", type: "Error") } } } @@ -71,6 +71,7 @@ extension DownloadManager: AVAssetDownloadDelegate { func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { guard let error = error else { return } + Logger.shared.log("Download failed: \(error.localizedDescription)", type: "Error") activeDownloadTasks.removeValue(forKey: task) } diff --git a/Sora/Utils/DownloadUtils/DownloadModels.swift b/Sora/Utils/DownloadUtils/DownloadModels.swift index 1278542..fd20229 100644 --- a/Sora/Utils/DownloadUtils/DownloadModels.swift +++ b/Sora/Utils/DownloadUtils/DownloadModels.swift @@ -32,13 +32,13 @@ enum DownloadQualityPreference: String, CaseIterable { var description: String { switch self { case .best: - return "Highest available quality (largest file size)" + return "Maximum quality available (largest file size)" case .high: - return "High quality (720p or higher)" + return "High quality (720p or better)" case .medium: - return "Medium quality (480p-720p)" + return "Medium quality (480p to 720p)" case .low: - return "Lowest available quality (smallest file size)" + return "Minimum quality available (smallest file size)" } } } diff --git a/Sora/Utils/DownloadUtils/M3U8StreamExtractor.swift b/Sora/Utils/DownloadUtils/M3U8StreamExtractor.swift index c0886b9..939a8e2 100644 --- a/Sora/Utils/DownloadUtils/M3U8StreamExtractor.swift +++ b/Sora/Utils/DownloadUtils/M3U8StreamExtractor.swift @@ -16,13 +16,13 @@ enum M3U8StreamExtractorError: Error { var localizedDescription: String { switch self { case .networkError(let error): - return "Network error: \(error.localizedDescription)" + return "Connection error: \(error.localizedDescription)" case .parsingError(let message): - return "Parsing error: \(message)" + return "Stream parsing error: \(message)" case .noStreamFound: - return "No suitable stream found in playlist" + return "No compatible stream found in playlist" case .invalidURL: - return "Invalid stream URL" + return "Stream URL is invalid" } } } diff --git a/Sora/Utils/Drops/DropManager.swift b/Sora/Utils/Drops/DropManager.swift index 8cdce3a..9170158 100644 --- a/Sora/Utils/Drops/DropManager.swift +++ b/Sora/Utils/Drops/DropManager.swift @@ -67,8 +67,8 @@ class DropManager { let willStartImmediately = JSController.shared.willDownloadStartImmediately() let message = willStartImmediately - ? "Episode \(episodeNumber) download started" - : "Episode \(episodeNumber) queued" + ? "Episode \(episodeNumber) is now downloading" + : "Episode \(episodeNumber) added to download queue" showDrop( title: willStartImmediately ? "Download Started" : "Download Queued", diff --git a/Sora/Utils/JSLoader/JSController-Search.swift b/Sora/Utils/JSLoader/JSController-Search.swift index 007019e..7eb04a2 100644 --- a/Sora/Utils/JSLoader/JSController-Search.swift +++ b/Sora/Utils/JSLoader/JSController-Search.swift @@ -21,18 +21,18 @@ extension JSController { guard let self = self else { return } if let error = error { - Logger.shared.log("Network error: \(error)",type: "Error") + Logger.shared.log("Network error while searching: \(error)", type: "Error") DispatchQueue.main.async { completion([]) } return } guard let data = data, let html = String(data: data, encoding: .utf8) else { - Logger.shared.log("Failed to decode HTML",type: "Error") + Logger.shared.log("Could not decode HTML response", type: "Error") DispatchQueue.main.async { completion([]) } return } - Logger.shared.log(html,type: "HTMLStrings") + Logger.shared.log(html, type: "HTMLStrings") if let parseFunction = self.context.objectForKeyedSubscript("searchResults"), let results = parseFunction.call(withArguments: [html]).toArray() as? [[String: String]] { let resultItems = results.map { item in @@ -46,7 +46,7 @@ extension JSController { completion(resultItems) } } else { - Logger.shared.log("Failed to parse results",type: "Error") + Logger.shared.log("Could not parse search results", type: "Error") DispatchQueue.main.async { completion([]) } } }.resume() @@ -54,27 +54,27 @@ extension JSController { func fetchJsSearchResults(keyword: String, module: ScrapingModule, completion: @escaping ([SearchItem]) -> Void) { if let exception = context.exception { - Logger.shared.log("JavaScript exception: \(exception)",type: "Error") + Logger.shared.log("JavaScript exception: \(exception)", type: "Error") completion([]) return } guard let searchResultsFunction = context.objectForKeyedSubscript("searchResults") else { - Logger.shared.log("No JavaScript function searchResults found",type: "Error") + Logger.shared.log("Search function not found in module", type: "Error") completion([]) return } let promiseValue = searchResultsFunction.call(withArguments: [keyword]) guard let promise = promiseValue else { - Logger.shared.log("searchResults did not return a Promise",type: "Error") + Logger.shared.log("Search function returned invalid response", type: "Error") completion([]) return } let thenBlock: @convention(block) (JSValue) -> Void = { result in - Logger.shared.log(result.toString(),type: "HTMLStrings") + Logger.shared.log(result.toString(), type: "HTMLStrings") if let jsonString = result.toString(), let data = jsonString.data(using: .utf8) { do { @@ -83,7 +83,7 @@ extension JSController { guard let title = item["title"] as? String, let imageUrl = item["image"] as? String, let href = item["href"] as? String else { - Logger.shared.log("Missing or invalid data in search result item: \(item)", type: "Error") + Logger.shared.log("Invalid search result data format", type: "Error") return nil } return SearchItem(title: title, imageUrl: imageUrl, href: href) @@ -94,19 +94,19 @@ extension JSController { } } else { - Logger.shared.log("Failed to parse JSON",type: "Error") + Logger.shared.log("Could not parse JSON response", type: "Error") DispatchQueue.main.async { completion([]) } } } catch { - Logger.shared.log("JSON parsing error: \(error)",type: "Error") + Logger.shared.log("JSON parsing error: \(error)", type: "Error") DispatchQueue.main.async { completion([]) } } } else { - Logger.shared.log("Result is not a string",type: "Error") + Logger.shared.log("Invalid search result format", type: "Error") DispatchQueue.main.async { completion([]) } @@ -114,7 +114,7 @@ extension JSController { } let catchBlock: @convention(block) (JSValue) -> Void = { error in - Logger.shared.log("Promise rejected: \(String(describing: error.toString()))",type: "Error") + Logger.shared.log("Search operation failed: \(String(describing: error.toString()))", type: "Error") DispatchQueue.main.async { completion([]) } diff --git a/Sora/Utils/MediaPlayer/VideoPlayer.swift b/Sora/Utils/MediaPlayer/VideoPlayer.swift index 4651392..bd65523 100644 --- a/Sora/Utils/MediaPlayer/VideoPlayer.swift +++ b/Sora/Utils/MediaPlayer/VideoPlayer.swift @@ -208,9 +208,9 @@ class VideoPlayerViewController: UIViewController { aniListMutation.updateAnimeProgress(animeId: self.aniListID, episodeNumber: self.episodeNumber) { result in switch result { case .success: - Logger.shared.log("Successfully updated AniList progress for episode \(self.episodeNumber)", type: "General") + Logger.shared.log("Updated AniList progress for Episode \(self.episodeNumber)", type: "General") case .failure(let error): - Logger.shared.log("Failed to update AniList progress: \(error.localizedDescription)", type: "Error") + Logger.shared.log("Could not update AniList progress: \(error.localizedDescription)", type: "Error") } } } @@ -222,9 +222,9 @@ class VideoPlayerViewController: UIViewController { traktMutation.markAsWatched(type: "movie", tmdbID: tmdbId) { result in switch result { case .success: - Logger.shared.log("Successfully updated Trakt progress for movie", type: "General") + Logger.shared.log("Updated Trakt progress for movie", type: "General") case .failure(let error): - Logger.shared.log("Failed to update Trakt progress: \(error.localizedDescription)", type: "Error") + Logger.shared.log("Could not update Trakt progress: \(error.localizedDescription)", type: "Error") } } } else { @@ -236,9 +236,9 @@ class VideoPlayerViewController: UIViewController { ) { result in switch result { case .success: - Logger.shared.log("Successfully updated Trakt progress for episode \(self.episodeNumber)", type: "General") + Logger.shared.log("Updated Trakt progress for Episode \(self.episodeNumber)", type: "General") case .failure(let error): - Logger.shared.log("Failed to update Trakt progress: \(error.localizedDescription)", type: "Error") + Logger.shared.log("Could not update Trakt progress: \(error.localizedDescription)", type: "Error") } } } diff --git a/Sora/Utils/WebAuthentication/WebAuthenticationManager.swift b/Sora/Utils/WebAuthentication/WebAuthenticationManager.swift index ba1b7f9..f0b06b2 100644 --- a/Sora/Utils/WebAuthentication/WebAuthenticationManager.swift +++ b/Sora/Utils/WebAuthentication/WebAuthenticationManager.swift @@ -21,7 +21,7 @@ class WebAuthenticationManager { if let callbackURL = callbackURL { completion(.success(callbackURL)) } else { - completion(.failure(NSError(domain: "WebAuthenticationManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "No callback URL received"]))) + completion(.failure(NSError(domain: "WebAuthenticationManager", code: -1, userInfo: [NSLocalizedDescriptionKey: "Authentication callback URL not received"]))) } } diff --git a/Sora/Views/DownloadView.swift b/Sora/Views/DownloadView.swift index 31afe1b..ffc5cbc 100644 --- a/Sora/Views/DownloadView.swift +++ b/Sora/Views/DownloadView.swift @@ -145,7 +145,7 @@ struct DownloadView: View { .fontWeight(.medium) .foregroundStyle(.primary) - Text("Actively downloading media can be tracked from here.") + Text("Your active downloads will appear here.") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -167,7 +167,7 @@ struct DownloadView: View { .fontWeight(.medium) .foregroundStyle(.primary) - Text("Your downloaded episodes will appear here") + Text("Your downloaded content will appear here") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -594,28 +594,6 @@ struct DownloadSummaryCard: View { } } - - private func formatFileSize(_ size: Int64) -> String { - let formatter = ByteCountFormatter() - formatter.allowedUnits = [.useKB, .useMB, .useGB] - formatter.countStyle = .file - return formatter.string(fromByteCount: size) - } - - private func formatFileSizeWithUnit(_ size: Int64) -> String { - let formatter = ByteCountFormatter() - formatter.allowedUnits = [.useKB, .useMB, .useGB] - formatter.countStyle = .file - - let formattedString = formatter.string(fromByteCount: size) - let components = formattedString.components(separatedBy: " ") - if components.count == 2 { - return "Size (\(components[1]))" - } - return "Size" - } - - struct SummaryItem: View { let title: String let value: String diff --git a/Sora/Views/LibraryView/AllWatching.swift b/Sora/Views/LibraryView/AllWatching.swift index 878ac9f..0921bdf 100644 --- a/Sora/Views/LibraryView/AllWatching.swift +++ b/Sora/Views/LibraryView/AllWatching.swift @@ -36,10 +36,10 @@ struct AllWatchingView: View { @State private var sortOption: SortOption = .dateAdded enum SortOption: String, CaseIterable { - case dateAdded = "Date Added" - case title = "Title" - case source = "Source" - case progress = "Progress" + case dateAdded = "Recently Added" + case title = "Series Title" + case source = "Content Source" + case progress = "Watch Progress" } var sortedItems: [ContinueWatchingItem] { diff --git a/Sora/Views/LibraryView/LibraryView.swift b/Sora/Views/LibraryView/LibraryView.swift index b25ae8e..66e282a 100644 --- a/Sora/Views/LibraryView/LibraryView.swift +++ b/Sora/Views/LibraryView/LibraryView.swift @@ -91,9 +91,9 @@ struct LibraryView: View { Image(systemName: "play.circle") .font(.largeTitle) .foregroundColor(.secondary) - Text("No items to continue watching.") + Text("Nothing to Continue Watching") .font(.headline) - Text("Recently watched content will appear here.") + Text("Your recently watched content will appear here") .font(.caption) .foregroundColor(.secondary) } diff --git a/Sora/Views/MediaInfoView/AnilistMatchPopupView.swift b/Sora/Views/MediaInfoView/AnilistMatchPopupView.swift index 6434ee8..975736c 100644 --- a/Sora/Views/MediaInfoView/AnilistMatchPopupView.swift +++ b/Sora/Views/MediaInfoView/AnilistMatchPopupView.swift @@ -43,7 +43,7 @@ struct AnilistMatchPopupView: View { .frame(maxWidth: .infinity) .padding() } else if results.isEmpty { - Text("No matches found") + Text("No AniList matches found") .font(.subheadline) .foregroundStyle(.gray) .frame(maxWidth: .infinity) @@ -161,7 +161,7 @@ struct AnilistMatchPopupView: View { } }) }, message: { - Text("Enter the AniList ID for this media") + Text("Enter the AniList ID for this series") }) } .onAppear(perform: fetchMatches) diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index 40f7166..bad3e72 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -227,24 +227,24 @@ private extension EpisodeCell { Group { if progress <= 0.9 { Button(action: markAsWatched) { - Label("Mark as Watched", systemImage: "checkmark.circle") + Label("Mark Episode as Watched", systemImage: "checkmark.circle") } } if progress != 0 { Button(action: resetProgress) { - Label("Reset Progress", systemImage: "arrow.counterclockwise") + Label("Reset Episode Progress", systemImage: "arrow.counterclockwise") } } if episodeIndex > 0 { Button(action: onMarkAllPrevious) { - Label("Mark All Previous Watched", systemImage: "checkmark.circle.fill") + Label("Mark Previous Episodes as Watched", systemImage: "checkmark.circle.fill") } } Button(action: downloadEpisode) { - Label("Download Episode", systemImage: "arrow.down.circle") + Label("Download This Episode", systemImage: "arrow.down.circle") } } } diff --git a/Sora/Views/SearchView/SearchStateView.swift b/Sora/Views/SearchView/SearchStateView.swift index f174398..618a273 100644 --- a/Sora/Views/SearchView/SearchStateView.swift +++ b/Sora/Views/SearchView/SearchStateView.swift @@ -27,9 +27,9 @@ struct SearchStateView: View { Image(systemName: "magnifyingglass") .font(.largeTitle) .foregroundColor(.secondary) - Text("No Results Found") + Text("No Search Results Found") .font(.headline) - Text("Try different keywords") + Text("Try different search terms") .font(.caption) .foregroundColor(.secondary) } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift index f9537a7..01b7bea 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift @@ -62,7 +62,7 @@ struct SettingsViewAbout: View { var body: some View { ScrollView { VStack(spacing: 24) { - SettingsSection(title: "App Info", footer: "Sora/Sulfur will always remain free with no ADs!") { + SettingsSection(title: "App Info", footer: "Sora/Sulfur will always remain free with no ads!") { HStack(alignment: .center, spacing: 16) { LazyImage(url: URL(string: "https://raw.githubusercontent.com/cranci1/Sora/refs/heads/dev/Sora/Assets.xcassets/AppIcon.appiconset/darkmode.png")) { state in if let uiImage = state.imageContainer?.image { @@ -81,7 +81,7 @@ struct SettingsViewAbout: View { Text("Sora") .font(.title) .bold() - Text("AKA Sulfur") + Text("Also known as Sulfur") .font(.caption) .foregroundColor(.secondary) } diff --git a/Sora/Views/SettingsView/SettingsView.swift b/Sora/Views/SettingsView/SettingsView.swift index 4970639..30be2ed 100644 --- a/Sora/Views/SettingsView/SettingsView.swift +++ b/Sora/Views/SettingsView/SettingsView.swift @@ -157,29 +157,29 @@ struct SettingsView: View { } VStack(alignment: .leading, spacing: 4) { - Text("MAIN") + Text("MAIN SETTINGS") .font(.footnote) .foregroundStyle(.gray) .padding(.horizontal, 20) VStack(spacing: 0) { NavigationLink(destination: SettingsViewGeneral()) { - SettingsNavigationRow(icon: "gearshape", title: "General Preferences") + SettingsNavigationRow(icon: "gearshape", title: "General Settings") } Divider().padding(.horizontal, 16) NavigationLink(destination: SettingsViewPlayer()) { - SettingsNavigationRow(icon: "play.circle", title: "Video Player") + SettingsNavigationRow(icon: "play.circle", title: "Player Settings") } Divider().padding(.horizontal, 16) NavigationLink(destination: SettingsViewDownloads()) { - SettingsNavigationRow(icon: "arrow.down.circle", title: "Download") + SettingsNavigationRow(icon: "arrow.down.circle", title: "Download Settings") } Divider().padding(.horizontal, 16) NavigationLink(destination: SettingsViewTrackers()) { - SettingsNavigationRow(icon: "square.stack.3d.up", title: "Trackers") + SettingsNavigationRow(icon: "square.stack.3d.up", title: "Tracking Services") } } .background(.ultraThinMaterial) @@ -202,7 +202,7 @@ struct SettingsView: View { } VStack(alignment: .leading, spacing: 4) { - Text("DATA/LOGS") + Text("DATA & LOGS") .font(.footnote) .foregroundStyle(.gray) .padding(.horizontal, 20) @@ -237,7 +237,7 @@ struct SettingsView: View { } VStack(alignment: .leading, spacing: 4) { - Text("INFOS") + Text("INFORMATION") .font(.footnote) .foregroundStyle(.gray) .padding(.horizontal, 20) @@ -261,7 +261,7 @@ struct SettingsView: View { Link(destination: URL(string: "https://discord.gg/x7hppDWFDZ")!) { SettingsNavigationRow( icon: "bubble.left.and.bubble.right", - title: "Join the Discord", + title: "Join Discord Community", isExternal: true, textColor: .gray ) @@ -271,7 +271,7 @@ struct SettingsView: View { Link(destination: URL(string: "https://github.com/cranci1/Sora/issues")!) { SettingsNavigationRow( icon: "exclamationmark.circle", - title: "Report an Issue", + title: "Report an Issue on GitHub", isExternal: true, textColor: .gray ) @@ -306,7 +306,7 @@ struct SettingsView: View { .padding(.horizontal, 20) } - Text("Running Sora \(version) - cranci1") + Text("Sora \(version) by cranci1") .font(.footnote) .foregroundStyle(.gray) .frame(maxWidth: .infinity, alignment: .center) From 375fe1806b9c82c21d52a992467e32100b4c8568 Mon Sep 17 00:00:00 2001 From: 50/50 <80717571+50n50@users.noreply.github.com> Date: Thu, 12 Jun 2025 21:54:58 +0200 Subject: [PATCH 39/52] merge it fg (#184) --- Sora/Localizable.xcstrings | 3356 ++++++++++++++++- Sora/Views/DownloadView.swift | 96 +- Sora/Views/MediaInfoView/MediaInfoView.swift | 45 +- .../SettingsSubViews/SettingsViewData.swift | 375 +- .../SettingsViewGeneral.swift | 140 +- .../SettingsSubViews/SettingsViewLogger.swift | 14 +- .../SettingsViewLoggerFilter.swift | 14 +- .../SettingsSubViews/SettingsViewModule.swift | 26 +- .../SettingsSubViews/SettingsViewPlayer.swift | 61 +- .../SettingsViewTrackers.swift | 100 +- Sora/Views/SettingsView/SettingsView.swift | 46 +- 11 files changed, 3689 insertions(+), 584 deletions(-) diff --git a/Sora/Localizable.xcstrings b/Sora/Localizable.xcstrings index b4acd89..05a1def 100644 --- a/Sora/Localizable.xcstrings +++ b/Sora/Localizable.xcstrings @@ -8,7 +8,14 @@ }, "%lld Episodes" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld Afleveringen" + } + } + } }, "%lld of %lld" : { "localizations" : { @@ -34,25 +41,208 @@ }, "%lld%% seen" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld%% gezien" + } + } + } }, "•" : { }, "About" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "About" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Over" + } + } + } + }, + "About Sora" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "About Sora" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Over Sora" + } + } + } + }, + "Active" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Active" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Actief" + } + } + } + }, + "Active Downloads" : { + }, + "Actively downloading media can be tracked from here." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Actively downloading media can be tracked from here." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Actief downloaden van media kan hier worden gevolgd." + } + } + } }, "Add Module" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Add Module" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Module Toevoegen" + } + } + } + }, + "Adjust the number of media items per row in portrait and landscape modes." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Adjust the number of media items per row in portrait and landscape modes." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Pas het aantal media-items per rij aan in staande en liggende modus." + } + } + } + }, + "Advanced" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Advanced" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Geavanceerd" + } + } + } + }, + "AKA Sulfur" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "AKA Sulfur" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "AKA Sulfur" + } + } + } }, "All Bookmarks" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "All Bookmarks" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Alle Bladwijzers" + } + } + } }, "All Prev" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle vorige" + } + } + } }, "All Watching" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "All Watching" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Alles Wat Ik Kijk" + } + } + } + }, + "AniList" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "AniList" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "AniList" + } + } + } }, "Also known as Sulfur" : { @@ -65,154 +255,929 @@ }, "AniList.co" : { + }, + "Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Anonieme gegevens worden verzameld om de app te verbeteren. Er worden geen persoonlijke gegevens verzameld. Dit kan op elk moment worden uitgeschakeld." + } + } + } }, "App Data" : { + }, + "App Info" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "App Info" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "App Info" + } + } + } + }, + "App Language" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "App Language" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "App Taal" + } + } + } + }, + "App Storage" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Storage" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "App Opslag" + } + } + } + }, + "Appearance" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Appearance" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Uiterlijk" + } + } + } }, "Are you sure you want to clear all cached data? This will help free up storage space." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Are you sure you want to clear all cached data? This will help free up storage space." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Weet je zeker dat je alle gecachte gegevens wilt wissen? Dit helpt opslagruimte vrij te maken." + } + } + } }, "Are you sure you want to delete '%@'?" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Are you sure you want to delete '%@'?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Weet je zeker dat je '%@' wilt verwijderen?" + } + } + } + }, + "Are you sure you want to delete all %d episodes in '%@'?" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Are you sure you want to delete all %1$d episodes in '%2$@'?" + } + } + } }, "Are you sure you want to delete all %lld episodes in '%@'?" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "new", "value" : "Are you sure you want to delete all %1$lld episodes in '%2$@'?" } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Weet je zeker dat je alle %1$lld afleveringen in '%2$@' wilt verwijderen?" + } } } }, "Are you sure you want to delete all downloaded assets? You can choose to clear only the library while preserving the downloaded files for future use." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Are you sure you want to delete all downloaded assets? You can choose to clear only the library while preserving the downloaded files for future use." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Weet je zeker dat je alle gedownloade bestanden wilt verwijderen? Je kunt ervoor kiezen om alleen de bibliotheek te wissen terwijl je de gedownloade bestanden voor later gebruik bewaart." + } + } + } }, "Are you sure you want to erase all app data? This action cannot be undone." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Are you sure you want to erase all app data? This action cannot be undone." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Weet je zeker dat je alle app-gegevens wilt wissen? Deze actie kan niet ongedaan worden gemaakt." + } + } + } }, "Are you sure you want to remove all downloaded media files (.mov, .mp4, .pkg)? This action cannot be undone." : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weet je zeker dat je alle gedownloade mediabestanden (.mov, .mp4, .pkg) wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.\n" + } + } + } }, "Are you sure you want to remove all files in the Documents folder? This will remove all modules." : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Weet je zeker dat je alle bestanden in de map Documenten wilt verwijderen? Dit zal alle modules verwijderen.\n" + } + } + } }, "Author" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auteur\n" + } + } + } + }, + "Background Enabled" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Background Enabled" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Achtergrond Ingeschakeld" + } + } + } }, "Bookmark items for an easier access later." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmark items for an easier access later." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Bladwijzer items voor eenvoudigere toegang later." + } + } + } }, "Bookmarks" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bookmarks" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Bladwijzers" + } + } + } + }, + "Bottom Padding" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Bottom Padding" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Onderste Padding" + } + } + } }, "Cancel" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cancel" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Annuleren" + } + } + } }, "Check out some community modules here!" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Check out some community modules here!" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Bekijk hier enkele community modules!" + } + } + } }, "Clear" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Wissen" + } + } + } }, "Clear All Downloads" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wis Alle Downloads" + } + } + } }, "Clear Cache" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wis Cache" + } + } + } }, "Clear Library Only" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alleen bibliotheek wissen\n" + } + } + } }, "Clear Logs" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wis Logs" + } + } + } }, "Click the plus button to add a module!" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Click the plus button to add a module!" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Klik op de plus-knop om een module toe te voegen!" + } + } + } }, "Continue Watching" : { - - }, - "Copy to Clipboard" : { - - }, - "Copy URL" : { - - }, - "cranci1" : { - - }, - "DATA & LOGS" : { - - }, - "DATA/LOGS" : { - "extractionState" : "stale", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Data & Logs" + "value" : "Continue Watching" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verder Kijken" + } + } + } + }, + "Continue Watching Episode %d" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue Watching Episode %d" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verder Kijken Aflevering %d" + } + } + } + }, + "Contributors" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Contributors" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Bijdragers" + } + } + } + }, + "Copied to Clipboard" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Copied to Clipboard" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gekopieerd naar Klembord" + } + } + } + }, + "Copy to Clipboard" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Copy to Clipboard" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Kopiëren naar Klembord" + } + } + } + }, + "Copy URL" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Copy URL" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "URL Kopiëren" + } + } + } + }, + "cranci1" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "cranci1" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "cranci1" + } + } + } + }, + "Dark" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Dark" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Donker" + } + } + } + }, + "DATA & LOGS" : { + + }, + "Debug" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Debug" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Debug" + } + } + } + }, + "Debugging and troubleshooting." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Debugging and troubleshooting." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Debuggen en probleemoplossing." } } } }, "Delete" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Verwijderen" + } + } + } }, "Delete All" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alles Wissen" + } + } + } }, "Delete All Downloads" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle Downloads Wissen" + } + } + } }, "Delete All Episodes" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle Afleveringen Wissen" + } + } + } }, "Delete Download" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloads Wissen" + } + } + } }, "Delete Episode" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afleveringen Wissen" + } + } + } + }, + "Double Tap to Seek" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Double Tap to Seek" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Dubbel Tikken om te Zoeken" + } + } + } + }, + "Double tapping the screen on it's sides will skip with the short tap setting." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Double tapping the screen on it's sides will skip with the short tap setting." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Dubbel tikken op de zijkanten van het scherm zal overslaan met de korte tik instelling." + } + } + } }, "Download" : { + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", + "value" : "Download" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Downloaden" + } + } + } + }, + "Download Episode" : { + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aflevering Downloaden" + } + } + } + }, + "Download Summary" : { + + }, + "Downloaded" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Downloaded" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Gedownload" + } + } + } + }, + "Downloaded Shows" : { + + }, + "Downloading" : { + + }, + "Downloads" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Downloads" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", "value" : "Downloads" } } } }, - "Download This Episode" : { - + "Enable Analytics" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enable Analytics" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Analytics Inschakelen" + } + } + } }, - "Downloads" : { - + "Enable Subtitles" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enable Subtitles" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Ondertiteling Inschakelen" + } + } + } }, - "Enter the AniList ID for this series" : { - + "Enter the AniList ID for this media" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Enter the AniList ID for this media" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Voer de AniList ID in voor deze media" + } + } + } }, "Episode %lld" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Episode %lld" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Aflevering %lld" + } + } + } }, "Episodes" : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Episodes" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afleveringen" + } + } + } }, "Episodes might not be available yet or there could be an issue with the source." : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Episodes might not be available yet or there could be an issue with the source." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Afleveringen zijn mogelijk nog niet beschikbaar of er is een probleem met de bron." + } + } + } + }, + "Episodes Range" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Episodes Range" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Afleveringen Bereik" + } + } + } }, "Erase" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwijden" + } + } + } + }, + "Erase all App Data" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Erase all App Data" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wis Alle App Data" + } + } + } }, "Erase App Data" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwijder App Data" + } + } + } }, "Error" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Error" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Fout" + } + } + } }, - "Error Fetching Results" : { - + "Errors and critical issues." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Errors and critical issues." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Fouten en kritieke problemen." + } + } + } + }, + "Failed to load contributors" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Failed to load contributors" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Laden van bijdragers mislukt" + } + } + } + }, + "Fetch Episode metadata" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Fetch Episode metadata" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Haal Aflevering Metadata op" + } + } + } }, "Failed to load contributors" : { "localizations" : { @@ -225,24 +1190,266 @@ } }, "Files Downloaded" : { - - }, - "General" : { - - }, - "General Preferences" : { - "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", - "value" : "General Settings" + "state" : "new", + "value" : "Files Downloaded" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Gedownloade Bestanden" } } } }, - "INFORMATION" : { - + "Font Size" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Font Size" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Lettergrootte" + } + } + } + }, + "Force Landscape" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Force Landscape" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Forceer Landschap" + } + } + } + }, + "General" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "General" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Algemeen" + } + } + } + }, + "General events and activities." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "General events and activities." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Algemene gebeurtenissen en activiteiten." + } + } + } + }, + "General Preferences" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "General Preferences" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Algemene Voorkeuren" + } + } + } + }, + "Hide Splash Screen" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hide Splash Screen" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Splash Screen Verbergen" + } + } + } + }, + "HLS video downloading." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "HLS video downloading." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "HLS video downloaden." + } + } + } + }, + "Hold Speed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Hold Speed" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Vasthouden Snelheid" + } + } + } + }, + "Info" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Info" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Info" + } + } + } + }, + "INFOS" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "INFOS" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "INFO" + } + } + } + }, + "Installed Modules" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installed Modules" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geïnstalleerde Modules" + } + } + } + }, + "Interface" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Interface" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Interface" + } + } + } + }, + "Join the Discord" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Join the Discord" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Word lid van de Discord" + } + } + } + }, + "Landscape Columns" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Landscape Columns" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Liggende Kolommen" + } + } + } + }, + "Language" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Language" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Taal" + } + } + } }, "INFOS" : { "extractionState" : "stale", @@ -267,10 +1474,70 @@ } }, "LESS" : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "LESS" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "MINDER" + } + } + } }, "Library" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Library" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Bibliotheek" + } + } + } + }, + "License (GPLv3.0)" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "License (GPLv3.0)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Licentie (GPLv3.0)" + } + } + } + }, + "Light" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Light" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Licht" + } + } + } }, "License (GPLv3.0)" : { "localizations" : { @@ -283,40 +1550,247 @@ } }, "Loading Episode %lld..." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Loading Episode %lld..." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Aflevering %lld laden..." + } + } + } }, "Loading logs..." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Loading logs..." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Logboeken laden..." + } + } + } }, "Loading module information..." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Loading module information..." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Module-informatie laden..." + } + } + } }, "Loading Stream" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Loading Stream" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Stream Laden" + } + } + } }, "Log Debug Info" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Log Debug Info" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Debug Info Loggen" + } + } + } }, "Log Filters" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Log Filters" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Log Filters" + } + } + } }, "Log In with AniList" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Log In with AniList" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Inloggen met AniList" + } + } + } }, "Log In with Trakt" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Log In with Trakt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Inloggen met Trakt" + } + } + } }, "Log Out from AniList" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Log Out from AniList" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Uitloggen van AniList" + } + } + } }, "Log Out from Trakt" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Log Out from Trakt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Uitloggen van Trakt" + } + } + } + }, + "Log Types" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Log Types" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logboek Types" + } + } + } + }, + "Logged in as" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Logged in as" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ingelogd als" + } + } + } }, "Logged in as " : { - + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Logged in as " + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Ingelogd als " + } + } + } }, "Logs" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Logs" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Logboeken" + } + } + } + }, + "Long press Skip" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Long press Skip" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Lang Drukken Overslaan" + } + } + } }, "MAIN" : { "extractionState" : "stale", @@ -329,14 +1803,51 @@ } } }, - "MAIN SETTINGS" : { - + "Main Developer" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Main Developer" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Hoofdontwikkelaar" + } + } + } + }, + "Mark All Previous Watched" : { + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Markeer alles als gezien\n" + } + } + } }, "Mark as Watched" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Markeer als gezien" + } + } + } }, - "Mark Episode as Watched" : { - + "Mark watched" : { + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Markeer als gezien" + } + } + } }, "Mark Previous Episodes as Watched" : { @@ -354,112 +1865,732 @@ "Match with AniList" : { }, - "Match with TMDB" : { - - }, - "Matched ID: %lld" : { - + "Matched with: %@" : { + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Match met: %@" + } + } + } }, "Max Concurrent Downloads" : { - + "localizations" : { + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Maximaal gelijktijdige downloads\n" + } + } + } }, "me frfr" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "me frfr" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "me frfr" + } + } + } + }, + "Media Grid Layout" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Media Grid Layout" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Media Raster Layout" + } + } + } + }, + "Media Player" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Media Player" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Media Speler" + } + } + } + }, + "Media View" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Media View" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Mediaweergave" + } + } + } + }, + "Metadata Provider" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Metadata Provider" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Metadata Provider" + } + } + } + }, + "Module Removed" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Module Removed" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Module Verwijderd" + } + } + } }, "Modules" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Modules" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Modules" + } + } + } }, "MODULES" : { }, "MORE" : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "MORE" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "MEER" + } + } + } }, "No Active Downloads" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Active Downloads" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Geen Actieve Downloads" + } + } + } }, "No AniList matches found" : { }, "No Data Available" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Data Available" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Geen Gegevens Beschikbaar" + } + } + } }, "No Downloads" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Downloads" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Geen Downloads" + } + } + } }, "No episodes available" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No episodes available" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Geen afleveringen beschikbaar" + } + } + } }, "No Episodes Available" : { - - }, - "No matches found" : { - "extractionState" : "stale", + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "No matches found. Try different keywords." + "value" : "No Episodes Available" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Geen Afleveringen Beschikbaar" + } + } + } + }, + "No items to continue watching." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No items to continue watching." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Geen items om verder te kijken." + } + } + } + }, + "No matches found" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No matches found" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Geen overeenkomsten gevonden" } } } }, "No Module Selected" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Module Selected" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Geen Module Geselecteerd" + } + } + } }, "No Modules" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Modules" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Geen Modules" + } + } + } }, - "No Search Results Found" : { - + "No Results Found" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "No Results Found" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Geen Resultaten Gevonden" + } + } + } }, - "Nothing to Continue Watching" : { - + "Note that the modules will be replaced only if there is a different version string inside the JSON file." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Note that the modules will be replaced only if there is a different version string inside the JSON file." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Let op: de modules worden alleen vervangen als er een andere versiestring in het JSON-bestand staat." + } + } + } }, "OK" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "OK" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "OK" + } + } + } }, "Open Community Library" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Community Library" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Open Community Bibliotheek" + } + } + } }, "Open in AniList" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Open in AniList" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Openen in AniList" + } + } + } }, "Original Poster" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Original Poster" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Originele Poster" + } + } + } + }, + "Paused" : { }, "Play" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Play" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Afspelen" + } + } + } }, "Player" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Player" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Speler" + } + } + } + }, + "Please restart the app to apply the language change." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Please restart the app to apply the language change." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Herstart de app om de taalwijziging toe te passen." + } + } + } }, "Please select a module from settings" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Please select a module from settings" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Selecteer een module uit de instellingen" + } + } + } }, - "Provider: %@" : { + "Portrait Columns" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Portrait Columns" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Staande Kolommen" + } + } + } + }, + "Progress bar Marker Color" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Progress bar Marker Color" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Voortgangsbalk Markeerkleur" + } + } + } + }, + "Queue" : { }, "Queued" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Queued" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "In Wachtrij" + } + } + } + }, + "Recently watched content will appear here." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Recently watched content will appear here." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Recent bekeken inhoud verschijnt hier." + } + } + } + }, + "Refresh Modules on Launch" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Refresh Modules on Launch" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Ververs Modules bij Opstarten" + } + } + } }, "Refresh Storage Info" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Refresh Storage Info" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Opslaginformatie Vernieuwen" + } + } + } + }, + "Remember Playback speed" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Remember Playback speed" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Onthoud Afspeelsnelheid" + } + } + } }, "Remove" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Remove" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Verwijderen" + } + } + } + }, + "Remove All Cache" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove All Cache" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwijder Alle Cache" + } + } + } + }, + "Remove All Documents" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove All Documents" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwijder Alle Documenten" + } + } + } }, "Remove Documents" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Remove Documents" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Documenten Verwijderen" + } + } + } }, "Remove Downloaded Media" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Remove Downloaded Media" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Gedownloade Media Verwijderen" + } + } + } + }, + "Remove Downloads" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove Downloads" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verwijder Downloads" + } + } + } }, "Remove from Bookmarks" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Remove from Bookmarks" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Verwijderen uit Bladwijzers" + } + } + } }, "Remove Item" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Remove Item" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Item Verwijderen" + } + } + } + }, + "Report an Issue" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Report an Issue" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rapporteer een Probleem" + } + } + } }, "Report an Issue" : { "extractionState" : "stale", @@ -473,49 +2604,198 @@ } }, "Reset" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reset" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Resetten" + } + } + } }, "Reset AniList ID" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Reset AniList ID" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "AniList ID Resetten" + } + } + } }, "Reset Episode Progress" : { }, "Reset progress" : { + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", + "value" : "Reset progress" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Voortgang resetten" + } + } + } + }, + "Reset Progress" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", "value" : "Reset Progress" } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Voortgang Resetten" + } + } + } + }, + "Restart Required" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Restart Required" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Herstart Vereist" + } + } + } + }, + "Running Sora %@ - cranci1" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Running Sora %@ - cranci1" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Sora %@ draait - cranci1" + } } } }, "Save" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Save" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Opslaan" + } + } + } }, "Search" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Search" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Zoeken" + } + } + } }, "Search downloads" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Search downloads" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Downloads zoeken" + } + } + } }, "Search for something..." : { "localizations" : { "en" : { "stringUnit" : { - "state" : "translated", + "state" : "new", "value" : "Search for something..." } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Zoek naar iets..." + } } } }, "Search..." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Search..." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Zoeken..." + } + } + } + }, + "Season %d" : { + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Search for something..." + "value" : "Season %d" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seizoen %d" } } } @@ -524,85 +2804,835 @@ }, "Segments Color" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Segments Color" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Segmenten Kleur" + } + } + } }, "Select Module" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Select Module" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Module Selecteren" + } + } + } }, "Set Custom AniList ID" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Set Custom AniList ID" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Aangepaste AniList ID Instellen" + } + } + } }, "Settings" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Settings" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Instellingen" + } + } + } + }, + "Shadow" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Shadow" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Schaduw" + } + } + } }, "Show More (%lld more characters)" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show More (%lld more characters)" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Meer Tonen (%lld meer tekens)" + } + } + } + }, + "Show PiP Button" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show PiP Button" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Toon PiP Knop" + } + } + } + }, + "Show Skip 85s Button" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Skip 85s Button" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Toon Overslaan 85s Knop" + } + } + } + }, + "Show Skip Intro / Outro Buttons" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Show Skip Intro / Outro Buttons" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Toon Overslaan Intro / Outro Knoppen" + } + } + } + }, + "Shows" : { + }, + "Size (%@)" : { + + }, + "Skip Settings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Skip Settings" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Overslaan Instellingen" + } + } + } + }, + "Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Sommige functies zijn beperkt tot de Sora en Standaard speler, zoals ForceLandscape, holdSpeed en aangepaste tijd overslaan stappen." + } + } + } }, "Sora" : { }, - "Sora %@ by cranci1" : { - + "Sora and cranci1 are not affiliated with AniList or Trakt in any way.\n\nAlso note that progress updates may not be 100% accurate." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sora and cranci1 are not affiliated with AniList or Trakt in any way.\n\nAlso note that progress updates may not be 100% accurate." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sora en cranci1 zijn op geen enkele manier verbonden met AniList of Trakt.\n\nHoud er ook rekening mee dat voortgangsupdates mogelijk niet 100% nauwkeurig zijn." + } + } + } + }, + "Sora GitHub Repository" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sora GitHub Repository" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sora GitHub Repository" + } + } + } + }, + "Sora/Sulfur will always remain free with no ADs!" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sora/Sulfur will always remain free with no ADs!" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Sora/Sulfur blijft altijd gratis zonder advertenties!" + } + } + } }, "Sort" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Sort" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Sorteren" + } + } + } + }, + "Speed Settings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Speed Settings" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Snelheidsinstellingen" + } + } + } + }, + "Start Watching" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start Watching" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start met Kijken" + } + } + } + }, + "Start Watching Episode %d" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start Watching Episode %d" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start met Kijken Aflevering %d" + } + } + } }, "Storage Used" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Storage Used" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Gebruikte Opslag" + } + } + } + }, + "Stream" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stream" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stream" + } + } + } + }, + "Streaming and video playback." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Streaming and video playback." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Streaming en video afspelen." + } + } + } + }, + "Subtitle Color" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Subtitle Color" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Ondertitelingskleur" + } + } + } + }, + "Subtitle Settings" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Subtitle Settings" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Ondertitelingsinstellingen" + } + } + } + }, + "Sync anime progress" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sync anime progress" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchroniseer anime voortgang" + } + } + } + }, + "Sync TV shows progress" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sync TV shows progress" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Synchroniseer TV series voortgang" + } + } + } + }, + "System" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "System" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Systeem" + } + } + } }, "Tap a title to override the current match." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Tap a title to override the current match." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Tik op een titel om de huidige match te overschrijven." + } + } + } + }, + "Tap Skip" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Tap Skip" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Tik Overslaan" + } + } + } }, "Tap to manage your modules" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Tap to manage your modules" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Tik om je modules te beheren" + } + } + } }, "Tap to select a module" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Tap to select a module" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Tik om een module te selecteren" + } + } + } + }, + "The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nDo not erase App Data unless you understand the consequences — it may cause the app to malfunction." : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nDo not erase App Data unless you understand the consequences — it may cause the app to malfunction." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "De app cache helpt de app om afbeeldingen sneller te laden.\n\nHet wissen van de Documents map zal alle gedownloade modules verwijderen.\n\nWis de App Data niet tenzij je de gevolgen begrijpt — het kan ervoor zorgen dat de app niet meer goed werkt." + } + } + } + }, + "The episode range controls how many episodes appear on each page. Episodes are grouped into sets (like 1–25, 26–50, and so on), allowing you to navigate through them more easily.\n\nFor episode metadata, it refers to the episode thumbnail and title, since sometimes it can contain spoilers." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The episode range controls how many episodes appear on each page. Episodes are grouped into sets (like 1–25, 26–50, and so on), allowing you to navigate through them more easily.\n\nFor episode metadata, it refers to the episode thumbnail and title, since sometimes it can contain spoilers." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Het afleveringen bereik bepaalt hoeveel afleveringen er op elke pagina verschijnen. Afleveringen worden gegroepeerd in sets (zoals 1-25, 26-50, enzovoort), waardoor je er gemakkelijker doorheen kunt navigeren.\n\nVoor aflevering metadata verwijst dit naar de aflevering miniatuur en titel, aangezien deze soms spoilers kunnen bevatten." + } + } + } }, "The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases." : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases." + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "De module heeft slechts één aflevering geleverd, dit is waarschijnlijk een film, daarom hebben we aparte schermen gemaakt voor deze gevallen." + } + } + } + }, + "Thumbnails Width" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Thumbnails Width" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Miniatuur Breedte" + } + } + } }, "TMDB Match" : { }, "Trackers" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Trackers" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Trackers" + } + } + } + }, + "Trakt" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trakt" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trakt" + } + } + } }, "Trakt.tv" : { }, - "Try different search terms" : { - + "Try different keywords" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Try different keywords" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Probeer andere zoekwoorden" + } + } + } }, - "Unable to fetch matches. Please try again later." : { - + "Two Finger Hold for Pause" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Two Finger Hold for Pause" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Twee Vingers Vasthouden voor Pauze" + } + } + } }, "Use TMDB Poster Image" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Use TMDB Poster Image" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "TMDB Poster Afbeelding Gebruiken" + } + } + } }, "v%@" : { + }, + "Video Player" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Video Player" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Videospeler" + } + } + } }, "View All" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "View All" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Alles Bekijken" + } + } + } }, "Watched" : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Watched" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Bekeken" + } + } + } }, "Why am I not seeing any episodes?" : { - + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Why am I not seeing any episodes?" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Waarom zie ik geen afleveringen?" + } + } + } + }, + "You are not logged in" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You are not logged in" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Je bent niet ingelogd" + } + } + } }, "You have no items saved." : { - + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "You have no items saved." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Je hebt geen items opgeslagen." + } + } + } }, - "Your active downloads will appear here." : { - + "Your downloaded episodes will appear here" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Your downloaded episodes will appear here" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Je gedownloade afleveringen verschijnen hier" + } + } + } }, - "Your downloaded content will appear here" : { - + "Video Quality Preferences" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Video Quality Preferences" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Video Kwaliteit Voorkeuren" + } + } + } }, - "Your recently watched content will appear here" : { - + "Choose preferred video resolution for WiFi and cellular connections. Higher resolutions use more data but provide better quality. If the exact quality isn't available, the closest option will be selected automatically.\n\nNote: Not all video sources and players support quality selection. This feature works best with HLS streams using the Sora player." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Choose preferred video resolution for WiFi and cellular connections. Higher resolutions use more data but provide better quality. If the exact quality isn't available, the closest option will be selected automatically.\n\nNote: Not all video sources and players support quality selection. This feature works best with HLS streams using the Sora player." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Kies de gewenste videoresolutie voor WiFi en mobiele verbindingen. Hogere resoluties gebruiken meer data maar bieden betere kwaliteit. Als de exacte kwaliteit niet beschikbaar is, wordt automatisch de dichtstbijzijnde optie geselecteerd.\n\nLet op: Niet alle videobronnen en spelers ondersteunen kwaliteitsselectie. Deze functie werkt het beste met HLS-streams met de Sora-speler." + } + } + } + }, + "WiFi Quality" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "WiFi Quality" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "WiFi Kwaliteit" + } + } + } + }, + "Cellular Quality" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cellular Quality" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Mobiele Kwaliteit" + } + } + } } }, "version" : "1.0" diff --git a/Sora/Views/DownloadView.swift b/Sora/Views/DownloadView.swift index ffc5cbc..8093104 100644 --- a/Sora/Views/DownloadView.swift +++ b/Sora/Views/DownloadView.swift @@ -57,16 +57,16 @@ struct DownloadView: View { } .animation(.easeInOut(duration: 0.2), value: selectedTab) .navigationBarHidden(true) - .alert("Delete Download", isPresented: $showDeleteAlert) { - Button("Delete", role: .destructive) { + .alert(NSLocalizedString("Delete Download", comment: ""), isPresented: $showDeleteAlert) { + Button(NSLocalizedString("Delete", comment: ""), role: .destructive) { if let asset = assetToDelete { jsController.deleteAsset(asset) } } - Button("Cancel", role: .cancel) {} + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) {} } message: { if let asset = assetToDelete { - Text("Are you sure you want to delete '\(asset.episodeDisplayName)'?") + Text(String(format: NSLocalizedString("Are you sure you want to delete '%@'?", comment: ""), asset.episodeDisplayName)) } } } @@ -83,7 +83,7 @@ struct DownloadView: View { VStack(spacing: 20) { if !jsController.downloadQueue.isEmpty { DownloadSectionView( - title: "Queue", + title: NSLocalizedString("Queue", comment: ""), icon: "clock.fill", downloads: jsController.downloadQueue ) @@ -91,7 +91,7 @@ struct DownloadView: View { if !jsController.activeDownloads.isEmpty { DownloadSectionView( - title: "Active Downloads", + title: NSLocalizedString("Active Downloads", comment: ""), icon: "arrow.down.circle.fill", downloads: jsController.activeDownloads ) @@ -140,12 +140,12 @@ struct DownloadView: View { .foregroundStyle(.tertiary) VStack(spacing: 8) { - Text("No Active Downloads") + Text(NSLocalizedString("No Active Downloads", comment: "")) .font(.title2) .fontWeight(.medium) .foregroundStyle(.primary) - Text("Your active downloads will appear here.") + Text(NSLocalizedString("Actively downloading media can be tracked from here.", comment: "")) .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -162,12 +162,12 @@ struct DownloadView: View { .foregroundStyle(.tertiary) VStack(spacing: 8) { - Text("No Downloads") + Text(NSLocalizedString("No Downloads", comment: "")) .font(.title2) .fontWeight(.medium) .foregroundStyle(.primary) - Text("Your downloaded content will appear here") + Text(NSLocalizedString("Your downloaded episodes will appear here", comment: "")) .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -274,7 +274,7 @@ struct CustomDownloadHeader: View { var body: some View { VStack(spacing: 0) { HStack { - Text("Downloads") + Text(NSLocalizedString("Downloads", comment: "")) .font(.largeTitle) .fontWeight(.bold) .foregroundStyle(.primary) @@ -348,7 +348,7 @@ struct CustomDownloadHeader: View { .frame(width: 18, height: 18) .foregroundColor(.secondary) - TextField("Search downloads", text: $searchText) + TextField(NSLocalizedString("Search downloads", comment: ""), text: $searchText) .textFieldStyle(PlainTextFieldStyle()) .foregroundColor(.primary) @@ -371,16 +371,16 @@ struct CustomDownloadHeader: View { .overlay( RoundedRectangle(cornerRadius: 12) .strokeBorder( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color.accentColor.opacity(0.25), location: 0), - .init(color: Color.accentColor.opacity(0), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 1.5 - ) + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color.accentColor.opacity(0.25), location: 0), + .init(color: Color.accentColor.opacity(0), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 1.5 + ) ) } .padding(.horizontal, 20) @@ -394,14 +394,14 @@ struct CustomDownloadHeader: View { VStack(spacing: 0) { HStack(spacing: 0) { TabButton( - title: "Active", + title: NSLocalizedString("Active", comment: ""), icon: "arrow.down.circle", isSelected: selectedTab == 0, action: { selectedTab = 0 } ) TabButton( - title: "Downloaded", + title: NSLocalizedString("Downloaded", comment: ""), icon: "checkmark.circle", isSelected: selectedTab == 1, action: { selectedTab = 1 } @@ -526,7 +526,7 @@ struct DownloadSummaryCard: View { HStack { Image(systemName: "chart.bar.fill") .foregroundColor(.accentColor) - Text("Download Summary".uppercased()) + Text(NSLocalizedString("Download Summary", comment: "").uppercased()) .font(.footnote) .fontWeight(.medium) .foregroundColor(.secondary) @@ -538,7 +538,7 @@ struct DownloadSummaryCard: View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 20) { SummaryItem( - title: "Shows", + title: NSLocalizedString("Shows", comment: ""), value: "\(totalShows)", icon: "tv.fill" ) @@ -546,7 +546,7 @@ struct DownloadSummaryCard: View { Divider().frame(height: 32) SummaryItem( - title: "Episodes", + title: NSLocalizedString("Episodes", comment: ""), value: "\(totalEpisodes)", icon: "play.rectangle.fill" ) @@ -559,7 +559,7 @@ struct DownloadSummaryCard: View { let sizeUnit = components.dropFirst().first.map(String.init) ?? "" SummaryItem( - title: "Size (\(sizeUnit))", + title: String(format: NSLocalizedString("Size (%@)", comment: ""), sizeUnit), value: sizeValue, icon: "internaldrive.fill" ) @@ -630,7 +630,7 @@ struct DownloadedSection: View { HStack { Image(systemName: "folder.fill") .foregroundColor(.accentColor) - Text("Downloaded Shows".uppercased()) + Text(NSLocalizedString("Downloaded Shows", comment: "").uppercased()) .font(.footnote) .fontWeight(.medium) .foregroundColor(.secondary) @@ -715,7 +715,7 @@ struct EnhancedActiveDownloadCard: View { VStack(spacing: 6) { HStack { if download.queueStatus == .queued { - Text("Queued") + Text(NSLocalizedString("Queued", comment: "")) .font(.caption) .fontWeight(.medium) .foregroundStyle(.orange) @@ -797,11 +797,11 @@ struct EnhancedActiveDownloadCard: View { private var statusText: String { if download.queueStatus == .queued { - return "Queued" + return NSLocalizedString("Queued", comment: "") } else if taskState == .running { - return "Downloading" + return NSLocalizedString("Downloading", comment: "") } else { - return "Paused" + return NSLocalizedString("Paused", comment: "") } } @@ -1026,7 +1026,7 @@ struct EnhancedShowEpisodesView: View { HStack { Image(systemName: "list.bullet.rectangle") .foregroundColor(.accentColor) - Text("Episodes".uppercased()) + Text(NSLocalizedString("Episodes", comment: "").uppercased()) .font(.footnote) .fontWeight(.medium) .foregroundColor(.secondary) @@ -1051,7 +1051,7 @@ struct EnhancedShowEpisodesView: View { } label: { HStack(spacing: 4) { Image(systemName: episodeSortOption.systemImage) - Text("Sort") + Text(NSLocalizedString("Sort", comment: "")) } .font(.subheadline) .foregroundColor(.accentColor) @@ -1062,7 +1062,7 @@ struct EnhancedShowEpisodesView: View { }) { HStack(spacing: 4) { Image(systemName: "trash") - Text("Delete All") + Text(NSLocalizedString("Delete All", comment: "")) } .font(.subheadline) .foregroundColor(.red) @@ -1073,7 +1073,7 @@ struct EnhancedShowEpisodesView: View { // Episodes List if group.assets.isEmpty { - Text("No episodes available") + Text(NSLocalizedString("No episodes available", comment: "")) .foregroundColor(.secondary) .italic() .padding(40) @@ -1086,7 +1086,7 @@ struct EnhancedShowEpisodesView: View { ) .contextMenu { Button(action: { onPlay(asset) }) { - Label("Play", systemImage: "play.fill") + Label(NSLocalizedString("Play", comment: ""), systemImage: "play.fill") } .disabled(!asset.fileExists) @@ -1094,7 +1094,7 @@ struct EnhancedShowEpisodesView: View { assetToDelete = asset showDeleteAlert = true }) { - Label("Delete", systemImage: "trash") + Label(NSLocalizedString("Delete", comment: ""), systemImage: "trash") } } .onTapGesture { @@ -1107,27 +1107,27 @@ struct EnhancedShowEpisodesView: View { } .padding(.vertical) } - .navigationTitle("Episodes") + .navigationTitle(NSLocalizedString("Episodes", comment: "")) .navigationBarTitleDisplayMode(.inline) - .alert("Delete Episode", isPresented: $showDeleteAlert) { - Button("Cancel", role: .cancel) { } - Button("Delete", role: .destructive) { + .alert(NSLocalizedString("Delete Episode", comment: ""), isPresented: $showDeleteAlert) { + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) { } + Button(NSLocalizedString("Delete", comment: ""), role: .destructive) { if let asset = assetToDelete { onDelete(asset) } } } message: { if let asset = assetToDelete { - Text("Are you sure you want to delete '\(asset.episodeDisplayName)'?") + Text(String(format: NSLocalizedString("Are you sure you want to delete '%@'?", comment: ""), asset.episodeDisplayName)) } } - .alert("Delete All Episodes", isPresented: $showDeleteAllAlert) { - Button("Cancel", role: .cancel) { } - Button("Delete All", role: .destructive) { + .alert(NSLocalizedString("Delete All Episodes", comment: ""), isPresented: $showDeleteAllAlert) { + Button(NSLocalizedString("Cancel", comment: ""), role: .cancel) { } + Button(NSLocalizedString("Delete All", comment: ""), role: .destructive) { deleteAllAssets() } } message: { - Text("Are you sure you want to delete all \(group.assetCount) episodes in '\(group.title)'?") + Text(String(format: NSLocalizedString("Are you sure you want to delete all %d episodes in '%@'?", comment: ""), group.assetCount, group.title)) } } diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index e1c69ad..0fdc24c 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -126,24 +126,33 @@ struct MediaInfoView: View { if episodeLinks.count == 1 { if let _ = unfinished { - return "Continue Watching" + return NSLocalizedString("Continue Watching", comment: "") } - return "Start Watching" + return NSLocalizedString("Start Watching", comment: "") } if let finishedIndex = finished, finishedIndex < episodeLinks.count - 1 { let nextEp = episodeLinks[finishedIndex + 1] - return "Start Watching Episode \(nextEp.number)" + return String(format: NSLocalizedString("Start Watching Episode %d", comment: ""), nextEp.number) } if let unfinishedIndex = unfinished { let currentEp = episodeLinks[unfinishedIndex] - return "Continue Watching Episode \(currentEp.number)" + return String(format: NSLocalizedString("Continue Watching Episode %d", comment: ""), currentEp.number) } - return "Start Watching" + return NSLocalizedString("Start Watching", comment: "") } + private var singleEpisodeWatchText: String { + if let ep = episodeLinks.first { + let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)") + let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)") + let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0 + return progress <= 0.9 ? NSLocalizedString("Mark watched", comment: "") : NSLocalizedString("Reset progress", comment: "") + } + return NSLocalizedString("Mark watched", comment: "") + } var body: some View { ZStack { @@ -336,7 +345,7 @@ struct MediaInfoView: View { .lineLimit(showFullSynopsis ? nil : 3) .animation(nil, value: showFullSynopsis) - Text(showFullSynopsis ? "LESS" : "MORE") + Text(showFullSynopsis ? NSLocalizedString("LESS", comment: "") : NSLocalizedString("MORE", comment: "")) .font(.system(size: 16, weight: .bold)) .foregroundColor(.accentColor) .animation(.easeInOut(duration: 0.3), value: showFullSynopsis) @@ -405,7 +414,7 @@ struct MediaInfoView: View { HStack(spacing: 4) { Image(systemName: "arrow.down.circle") .foregroundColor(.primary) - Text("Download") + Text(NSLocalizedString("Download", comment: "")) .font(.system(size: 14, weight: .medium)) .foregroundColor(.primary) } @@ -420,13 +429,13 @@ struct MediaInfoView: View { } VStack(spacing: 4) { - Text("Why am I not seeing any episodes?") + Text(NSLocalizedString("Why am I not seeing any episodes?", comment: "")) .font(.caption) .bold() .foregroundColor(.gray) .frame(maxWidth: .infinity, alignment: .leading) - Text("The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases.") + Text(NSLocalizedString("The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases.", comment: "")) .font(.caption) .foregroundColor(.gray) .multilineTextAlignment(.leading) @@ -451,16 +460,6 @@ struct MediaInfoView: View { return "checkmark.circle" } - private var singleEpisodeWatchText: String { - if let ep = episodeLinks.first { - let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)") - let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)") - let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0 - return progress <= 0.9 ? "Mark watched" : "Reset progress" - } - return "Mark watched" - } - @ViewBuilder private var episodesSection: some View { if episodeLinks.count != 1 { @@ -474,7 +473,7 @@ struct MediaInfoView: View { @ViewBuilder private var episodesSectionHeader: some View { HStack { - Text("Episodes") + Text(NSLocalizedString("Episodes", comment: "")) .font(.system(size: 22, weight: .bold)) .foregroundColor(.primary) @@ -524,7 +523,7 @@ struct MediaInfoView: View { Menu { ForEach(0.. Int64 { + let fileManager = FileManager.default + var totalSize: Int64 = 0 + let mediaExtensions = [".mov", ".mp4", ".pkg"] + + do { + let contents = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.fileSizeKey, .isDirectoryKey]) + for url in contents { + let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey]) + if resourceValues.isDirectory == true { + totalSize += calculateMediaFilesSize(in: url) } else { - DispatchQueue.main.async { - self.cacheSizeText = "N/A" - self.isCalculatingSize = false - } - } - } - } - - func updateSizes() { - DispatchQueue.global(qos: .background).async { - if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { - let size = calculateDirectorySize(for: documentsURL) - DispatchQueue.main.async { - self.documentsSize = size - } - } - } - } - - func calculateDownloadsSize() { - DispatchQueue.global(qos: .background).async { - if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first { - let size = calculateMediaFilesSize(in: documentsURL) - DispatchQueue.main.async { - self.downloadsSize = size - } - } - } - } - - func calculateMediaFilesSize(in directory: URL) -> Int64 { - let fileManager = FileManager.default - var totalSize: Int64 = 0 - let mediaExtensions = [".mov", ".mp4", ".pkg"] - - do { - let contents = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.fileSizeKey, .isDirectoryKey]) - for url in contents { - let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey]) - if resourceValues.isDirectory == true { - totalSize += calculateMediaFilesSize(in: url) - } else { - let fileExtension = url.pathExtension.lowercased() - if mediaExtensions.contains(".\(fileExtension)") { - totalSize += Int64(resourceValues.fileSize ?? 0) - } - } - } - } catch { - Logger.shared.log("Error calculating media files size: \(error)", type: "Error") - } - - return totalSize - } - - func clearAllCaches() { - clearCache() - } - - func clearCache() { - let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first - do { - if let cacheURL = cacheURL { - let filePaths = try FileManager.default.contentsOfDirectory(at: cacheURL, includingPropertiesForKeys: nil, options: []) - for filePath in filePaths { - try FileManager.default.removeItem(at: filePath) - } - Logger.shared.log("Cache cleared successfully!", type: "General") - calculateCacheSize() - updateSizes() - calculateDownloadsSize() - } - } catch { - Logger.shared.log("Failed to clear cache.", type: "Error") - } - } - - func removeDownloadedMedia() { - let fileManager = FileManager.default - let mediaExtensions = [".mov", ".mp4", ".pkg"] - - if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { - removeMediaFiles(in: documentsURL, extensions: mediaExtensions) - Logger.shared.log("Downloaded media files removed", type: "General") - updateSizes() - calculateDownloadsSize() - } - } - - func removeMediaFiles(in directory: URL, extensions: [String]) { - let fileManager = FileManager.default - - do { - let contents = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.isDirectoryKey]) - for url in contents { - let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey]) - if resourceValues.isDirectory == true { - removeMediaFiles(in: url, extensions: extensions) - } else { - let fileExtension = ".\(url.pathExtension.lowercased())" - if extensions.contains(fileExtension) { - try fileManager.removeItem(at: url) - Logger.shared.log("Removed media file: \(url.lastPathComponent)", type: "General") - } - } - } - } catch { - Logger.shared.log("Error removing media files in \(directory.path): \(error)", type: "Error") - } - } - - func removeAllFilesInDocuments() { - let fileManager = FileManager.default - if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { - do { - let fileURLs = try fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil) - for fileURL in fileURLs { - try fileManager.removeItem(at: fileURL) - } - Logger.shared.log("All files in documents folder removed", type: "General") - exit(0) - } catch { - Logger.shared.log("Error removing files in documents folder: \(error)", type: "Error") - } - } - } - - func eraseAppData() { - if let domain = Bundle.main.bundleIdentifier { - UserDefaults.standard.removePersistentDomain(forName: domain) - UserDefaults.standard.synchronize() - Logger.shared.log("Cleared app data!", type: "General") - exit(0) - } - } - - func calculateDirectorySize(for url: URL) -> Int64 { - let fileManager = FileManager.default - var totalSize: Int64 = 0 - - do { - let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.fileSizeKey]) - for url in contents { - let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey]) - if resourceValues.isDirectory == true { - totalSize += calculateDirectorySize(for: url) - } else { + let fileExtension = url.pathExtension.lowercased() + if mediaExtensions.contains(".\(fileExtension)") { totalSize += Int64(resourceValues.fileSize ?? 0) } } - } catch { - Logger.shared.log("Error calculating directory size: \(error)", type: "Error") } - - return totalSize + } catch { + Logger.shared.log("Error calculating media files size: \(error)", type: "Error") } - func formatSize(_ bytes: Int64) -> String { - let formatter = ByteCountFormatter() - formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB] - formatter.countStyle = .file - return formatter.string(fromByteCount: bytes) + return totalSize + } + + func clearAllCaches() { + clearCache() + } + + func clearCache() { + let cacheURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + do { + if let cacheURL = cacheURL { + let filePaths = try FileManager.default.contentsOfDirectory(at: cacheURL, includingPropertiesForKeys: nil, options: []) + for filePath in filePaths { + try FileManager.default.removeItem(at: filePath) + } + Logger.shared.log("Cache cleared successfully!", type: "General") + calculateCacheSize() + updateSizes() + calculateDownloadsSize() + } + } catch { + Logger.shared.log("Failed to clear cache.", type: "Error") } } + + func removeDownloadedMedia() { + let fileManager = FileManager.default + let mediaExtensions = [".mov", ".mp4", ".pkg"] + + if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { + removeMediaFiles(in: documentsURL, extensions: mediaExtensions) + Logger.shared.log("Downloaded media files removed", type: "General") + updateSizes() + calculateDownloadsSize() + } + } + + func removeMediaFiles(in directory: URL, extensions: [String]) { + let fileManager = FileManager.default + + do { + let contents = try fileManager.contentsOfDirectory(at: directory, includingPropertiesForKeys: [.isDirectoryKey]) + for url in contents { + let resourceValues = try url.resourceValues(forKeys: [.isDirectoryKey]) + if resourceValues.isDirectory == true { + removeMediaFiles(in: url, extensions: extensions) + } else { + let fileExtension = ".\(url.pathExtension.lowercased())" + if extensions.contains(fileExtension) { + try fileManager.removeItem(at: url) + Logger.shared.log("Removed media file: \(url.lastPathComponent)", type: "General") + } + } + } + } catch { + Logger.shared.log("Error removing media files in \(directory.path): \(error)", type: "Error") + } + } + + func removeAllFilesInDocuments() { + let fileManager = FileManager.default + if let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first { + do { + let fileURLs = try fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil) + for fileURL in fileURLs { + try fileManager.removeItem(at: fileURL) + } + Logger.shared.log("All files in documents folder removed", type: "General") + exit(0) + } catch { + Logger.shared.log("Error removing files in documents folder: \(error)", type: "Error") + } + } + } + + func eraseAppData() { + if let domain = Bundle.main.bundleIdentifier { + UserDefaults.standard.removePersistentDomain(forName: domain) + UserDefaults.standard.synchronize() + Logger.shared.log("Cleared app data!", type: "General") + exit(0) + } + } + + func calculateDirectorySize(for url: URL) -> Int64 { + let fileManager = FileManager.default + var totalSize: Int64 = 0 + + do { + let contents = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.fileSizeKey]) + for url in contents { + let resourceValues = try url.resourceValues(forKeys: [.fileSizeKey, .isDirectoryKey]) + if resourceValues.isDirectory == true { + totalSize += calculateDirectorySize(for: url) + } else { + totalSize += Int64(resourceValues.fileSize ?? 0) + } + } + } catch { + Logger.shared.log("Error calculating directory size: \(error)", type: "Error") + } + + return totalSize + } + + func formatSize(_ bytes: Int64) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useBytes, .useKB, .useMB, .useGB] + formatter.countStyle = .file + return formatter.string(fromByteCount: bytes) + } } + diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift index e7b6b61..7663138 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift @@ -168,20 +168,21 @@ struct SettingsViewGeneral: View { private let TMDBimageWidhtList = ["300", "500", "780", "1280", "original"] private let sortOrderOptions = ["Ascending", "Descending"] @EnvironmentObject var settings: Settings + @State private var showRestartAlert = false var body: some View { ScrollView { VStack(spacing: 24) { - SettingsSection(title: "Interface") { + SettingsSection(title: NSLocalizedString("Interface", comment: "")) { SettingsPickerRow( icon: "paintbrush", - title: "Appearance", + title: NSLocalizedString("Appearance", comment: ""), options: [Appearance.system, .light, .dark], optionToString: { appearance in switch appearance { - case .system: return "System" - case .light: return "Light" - case .dark: return "Dark" + case .system: return NSLocalizedString("System", comment: "") + case .light: return NSLocalizedString("Light", comment: "") + case .dark: return NSLocalizedString("Dark", comment: "") } }, selection: $settings.selectedAppearance @@ -189,19 +190,32 @@ struct SettingsViewGeneral: View { SettingsToggleRow( icon: "wand.and.rays.inverse", - title: "Hide Splash Screen", + title: NSLocalizedString("Hide Splash Screen", comment: ""), isOn: $hideSplashScreenEnable, showDivider: false ) } + SettingsSection(title: NSLocalizedString("Language", comment: "")) { + SettingsPickerRow( + icon: "globe", + title: NSLocalizedString("App Language", comment: ""), + options: ["English", "Dutch"], + optionToString: { $0 }, + selection: $settings.selectedLanguage + ) + .onChange(of: settings.selectedLanguage) { _ in + showRestartAlert = true + } + } + SettingsSection( - title: "Media View", - footer: "The episode range controls how many episodes appear on each page. Episodes are grouped into sets (like 1–25, 26–50, and so on), allowing you to navigate through them more easily.\n\nFor episode metadata, it refers to the episode thumbnail and title, since sometimes it can contain spoilers." + title: NSLocalizedString("Media View", comment: ""), + footer: NSLocalizedString("The episode range controls how many episodes appear on each page. Episodes are grouped into sets (like 1–25, 26–50, and so on), allowing you to navigate through them more easily.\n\nFor episode metadata, it refers to the episode thumbnail and title, since sometimes it can contain spoilers.", comment: "") ) { SettingsPickerRow( icon: "list.number", - title: "Episodes Range", + title: NSLocalizedString("Episodes Range", comment: ""), options: [25, 50, 75, 100], optionToString: { "\($0)" }, selection: $episodeChunkSize @@ -209,7 +223,7 @@ struct SettingsViewGeneral: View { SettingsToggleRow( icon: "info.circle", - title: "Fetch Episode metadata", + title: NSLocalizedString("Fetch Episode metadata", comment: ""), isOn: $fetchEpisodeMetadata ) @@ -232,51 +246,91 @@ struct SettingsViewGeneral: View { footer: "Adjust the number of media items per row in portrait and landscape modes." ) { SettingsPickerRow( - icon: "rectangle.portrait", - title: "Portrait Columns", - options: UIDevice.current.userInterfaceIdiom == .pad ? Array(1...5) : Array(1...4), - optionToString: { "\($0)" }, - selection: $mediaColumnsPortrait + icon: "server.rack", + title: NSLocalizedString("Metadata Provider", comment: ""), + options: metadataProvidersList, + optionToString: { $0 }, + selection: $metadataProviders, + showDivider: true ) SettingsPickerRow( - icon: "rectangle", - title: "Landscape Columns", - options: UIDevice.current.userInterfaceIdiom == .pad ? Array(2...8) : Array(2...5), - optionToString: { "\($0)" }, - selection: $mediaColumnsLandscape, + icon: "square.stack.3d.down.right", + title: NSLocalizedString("Thumbnails Width", comment: ""), + options: TMDBimageWidhtList, + optionToString: { $0 }, + selection: $TMDBimageWidht, showDivider: false ) - } - - SettingsSection( - title: "Modules", - footer: "Note that the modules will be replaced only if there is a different version string inside the JSON file." - ) { - SettingsToggleRow( - icon: "arrow.clockwise", - title: "Refresh Modules on Launch", - isOn: $refreshModulesOnLaunch, - showDivider: false - ) - } - - SettingsSection( - title: "Advanced", - footer: "Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time." - ) { - SettingsToggleRow( - icon: "chart.bar", - title: "Enable Analytics", - isOn: $analyticsEnabled, + } else { + SettingsPickerRow( + icon: "server.rack", + title: NSLocalizedString("Metadata Provider", comment: ""), + options: metadataProvidersList, + optionToString: { $0 }, + selection: $metadataProviders, showDivider: false ) } } - .padding(.vertical, 20) + + SettingsSection( + title: NSLocalizedString("Media Grid Layout", comment: ""), + footer: NSLocalizedString("Adjust the number of media items per row in portrait and landscape modes.", comment: "") + ) { + SettingsPickerRow( + icon: "rectangle.portrait", + title: NSLocalizedString("Portrait Columns", comment: ""), + options: UIDevice.current.userInterfaceIdiom == .pad ? Array(1...5) : Array(1...4), + optionToString: { "\($0)" }, + selection: $mediaColumnsPortrait + ) + + SettingsPickerRow( + icon: "rectangle", + title: NSLocalizedString("Landscape Columns", comment: ""), + options: UIDevice.current.userInterfaceIdiom == .pad ? Array(2...8) : Array(2...5), + optionToString: { "\($0)" }, + selection: $mediaColumnsLandscape, + showDivider: false + ) + } + + SettingsSection( + title: NSLocalizedString("Modules", comment: ""), + footer: NSLocalizedString("Note that the modules will be replaced only if there is a different version string inside the JSON file.", comment: "") + ) { + SettingsToggleRow( + icon: "arrow.clockwise", + title: NSLocalizedString("Refresh Modules on Launch", comment: ""), + isOn: $refreshModulesOnLaunch, + showDivider: false + ) + } + + SettingsSection( + title: NSLocalizedString("Advanced", comment: ""), + footer: NSLocalizedString("Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time.", comment: "") + ) { + SettingsToggleRow( + icon: "chart.bar", + title: NSLocalizedString("Enable Analytics", comment: ""), + isOn: $analyticsEnabled, + showDivider: false + ) + } } .navigationTitle("General") .scrollViewBottomPadding() } + .navigationTitle(NSLocalizedString("General", comment: "")) + .scrollViewBottomPadding() + .alert(isPresented: $showRestartAlert) { + Alert( + title: Text(NSLocalizedString("Restart Required", comment: "")), + message: Text(NSLocalizedString("Please restart the app to apply the language change.", comment: "")), + dismissButton: .default(Text("OK")) + ) + } } } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLogger.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLogger.swift index c62d5ff..ddb935b 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLogger.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLogger.swift @@ -76,12 +76,12 @@ struct SettingsViewLogger: View { var body: some View { ScrollView { VStack(spacing: 24) { - SettingsSection(title: "Logs") { + SettingsSection(title: NSLocalizedString("Logs", comment: "")) { if isLoading { HStack { ProgressView() .scaleEffect(0.8) - Text("Loading logs...") + Text(NSLocalizedString("Loading logs...", comment: "")) .font(.footnote) .foregroundColor(.secondary) } @@ -99,7 +99,7 @@ struct SettingsViewLogger: View { Button(action: { showFullLogs = true }) { - Text("Show More (\(logs.count - displayCharacterLimit) more characters)") + Text(NSLocalizedString("Show More (%lld more characters)", comment: "").replacingOccurrences(of: "%lld", with: "\(logs.count - displayCharacterLimit)")) .font(.footnote) .foregroundColor(.accentColor) } @@ -113,7 +113,7 @@ struct SettingsViewLogger: View { } .padding(.vertical, 20) } - .navigationTitle("Logs") + .navigationTitle(NSLocalizedString("Logs", comment: "")) .onAppear { loadLogsAsync() } @@ -123,14 +123,14 @@ struct SettingsViewLogger: View { Menu { Button(action: { UIPasteboard.general.string = logs - DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill")) + DropManager.shared.showDrop(title: NSLocalizedString("Copied to Clipboard", comment: ""), subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill")) }) { - Label("Copy to Clipboard", systemImage: "doc.on.doc") + Label(NSLocalizedString("Copy to Clipboard", comment: ""), systemImage: "doc.on.doc") } Button(role: .destructive, action: { clearLogsAsync() }) { - Label("Clear Logs", systemImage: "trash") + Label(NSLocalizedString("Clear Logs", comment: ""), systemImage: "trash") } } label: { Image(systemName: "ellipsis.circle") diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLoggerFilter.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLoggerFilter.swift index 6588afc..3b6e3c7 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLoggerFilter.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewLoggerFilter.swift @@ -115,11 +115,11 @@ class LogFilterViewModel: ObservableObject { private let userDefaultsKey = "LogFilterStates" private let hardcodedFilters: [(type: String, description: String, defaultState: Bool)] = [ - ("General", "General events and activities.", true), - ("Stream", "Streaming and video playback.", true), - ("Error", "Errors and critical issues.", true), - ("Debug", "Debugging and troubleshooting.", false), - ("Download", "HLS video downloading.", true), + (NSLocalizedString("General", comment: ""), NSLocalizedString("General events and activities.", comment: ""), true), + (NSLocalizedString("Stream", comment: ""), NSLocalizedString("Streaming and video playback.", comment: ""), true), + (NSLocalizedString("Error", comment: ""), NSLocalizedString("Errors and critical issues.", comment: ""), true), + (NSLocalizedString("Debug", comment: ""), NSLocalizedString("Debugging and troubleshooting.", comment: ""), false), + (NSLocalizedString("Download", comment: ""), NSLocalizedString("HLS video downloading.", comment: ""), true), ("HTMLStrings", "", false) ] @@ -179,7 +179,7 @@ struct SettingsViewLoggerFilter: View { var body: some View { ScrollView { VStack(spacing: 24) { - SettingsSection(title: "Log Types") { + SettingsSection(title: NSLocalizedString("Log Types", comment: "")) { ForEach($viewModel.filters) { $filter in SettingsToggleRow( icon: iconForFilter(filter.type), @@ -192,6 +192,6 @@ struct SettingsViewLoggerFilter: View { } .padding(.vertical, 20) } - .navigationTitle("Log Filters") + .navigationTitle(NSLocalizedString("Log Filters", comment: "")) } } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift index a5611d9..9e03433 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewModule.swift @@ -119,16 +119,16 @@ fileprivate struct ModuleListItemView: View { .contextMenu { Button(action: { UIPasteboard.general.string = module.metadataUrl - DropManager.shared.showDrop(title: "Copied to Clipboard", subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill")) + DropManager.shared.showDrop(title: NSLocalizedString("Copied to Clipboard", comment: ""), subtitle: "", duration: 1.0, icon: UIImage(systemName: "doc.on.clipboard.fill")) }) { - Label("Copy URL", systemImage: "doc.on.doc") + Label(NSLocalizedString("Copy URL", comment: ""), systemImage: "doc.on.doc") } Button(role: .destructive) { if selectedModuleId != module.id.uuidString { onDelete() } } label: { - Label("Delete", systemImage: "trash") + Label(NSLocalizedString("Delete", comment: ""), systemImage: "trash") } .disabled(selectedModuleId == module.id.uuidString) } @@ -137,7 +137,7 @@ fileprivate struct ModuleListItemView: View { Button(role: .destructive) { onDelete() } label: { - Label("Delete", systemImage: "trash") + Label(NSLocalizedString("Delete", comment: ""), systemImage: "trash") } } } @@ -163,25 +163,25 @@ struct SettingsViewModule: View { ScrollView { VStack(spacing: 24) { if moduleManager.modules.isEmpty { - SettingsSection(title: "Modules") { + SettingsSection(title: NSLocalizedString("Modules", comment: "")) { VStack(spacing: 16) { Image(systemName: "plus.app") .font(.largeTitle) .foregroundColor(.secondary) - Text("No Modules") + Text(NSLocalizedString("No Modules", comment: "")) .font(.headline) if didReceiveDefaultPageLink { NavigationLink(destination: CommunityLibraryView() .environmentObject(moduleManager)) { - Text("Check out some community modules here!") + Text(NSLocalizedString("Check out some community modules here!", comment: "")) .font(.caption) .foregroundColor(.accentColor) .frame(maxWidth: .infinity) } .buttonStyle(PlainButtonStyle()) } else { - Text("Click the plus button to add a module!") + Text(NSLocalizedString("Click the plus button to add a module!", comment: "")) .font(.caption) .foregroundColor(.secondary) .frame(maxWidth: .infinity) @@ -191,14 +191,14 @@ struct SettingsViewModule: View { .frame(maxWidth: .infinity) } } else { - SettingsSection(title: "Installed Modules") { + SettingsSection(title: NSLocalizedString("Installed Modules", comment: "")) { ForEach(moduleManager.modules) { module in ModuleListItemView( module: module, selectedModuleId: selectedModuleId, onDelete: { moduleManager.deleteModule(module) - DropManager.shared.showDrop(title: "Module Removed", subtitle: "", duration: 1.0, icon: UIImage(systemName: "trash")) + DropManager.shared.showDrop(title: NSLocalizedString("Module Removed", comment: ""), subtitle: "", duration: 1.0, icon: UIImage(systemName: "trash")) }, onSelect: { selectedModuleId = module.id.uuidString @@ -216,7 +216,7 @@ struct SettingsViewModule: View { .padding(.vertical, 20) } .scrollViewBottomPadding() - .navigationTitle("Modules") + .navigationTitle(NSLocalizedString("Modules", comment: "")) .navigationBarItems(trailing: HStack(spacing: 16) { if didReceiveDefaultPageLink { @@ -228,7 +228,7 @@ struct SettingsViewModule: View { .frame(width: 20, height: 20) .padding(5) } - .accessibilityLabel("Open Community Library") + .accessibilityLabel(NSLocalizedString("Open Community Library", comment: "")) } Button(action: { @@ -239,7 +239,7 @@ struct SettingsViewModule: View { .frame(width: 20, height: 20) .padding(5) } - .accessibilityLabel("Add Module") + .accessibilityLabel(NSLocalizedString("Add Module", comment: "")) } ) .background( diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift index 6eceb11..e5f182e 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewPlayer.swift @@ -215,12 +215,12 @@ struct SettingsViewPlayer: View { ScrollView { VStack(spacing: 24) { SettingsSection( - title: "Media Player", - footer: "Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments." + title: NSLocalizedString("Media Player", comment: ""), + footer: NSLocalizedString("Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments.", comment: "") ) { SettingsPickerRow( icon: "play.circle", - title: "Media Player", + title: NSLocalizedString("Media Player", comment: ""), options: mediaPlayers, optionToString: { $0 }, selection: $externalPlayer @@ -228,35 +228,35 @@ struct SettingsViewPlayer: View { SettingsToggleRow( icon: "rotate.right", - title: "Force Landscape", + title: NSLocalizedString("Force Landscape", comment: ""), isOn: $isAlwaysLandscape ) SettingsToggleRow( icon: "hand.tap", - title: "Two Finger Hold for Pause", + title: NSLocalizedString("Two Finger Hold for Pause", comment: ""), isOn: $holdForPauseEnabled, showDivider: true ) SettingsToggleRow( icon: "pip", - title: "Show PiP Button", + title: NSLocalizedString("Show PiP Button", comment: ""), isOn: $pipButtonVisible, showDivider: false ) } - SettingsSection(title: "Speed Settings") { + SettingsSection(title: NSLocalizedString("Speed Settings", comment: "")) { SettingsToggleRow( icon: "speedometer", - title: "Remember Playback speed", + title: NSLocalizedString("Remember Playback speed", comment: ""), isOn: $isRememberPlaySpeed ) SettingsStepperRow( icon: "forward.fill", - title: "Hold Speed", + title: NSLocalizedString("Hold Speed", comment: ""), value: $holdSpeedPlayer, range: 0.25...2.5, step: 0.25, @@ -264,14 +264,13 @@ struct SettingsViewPlayer: View { showDivider: false ) } - SettingsSection( - title: "Video Quality Preferences", - footer: "Choose preferred video resolution for WiFi and cellular connections. Higher resolutions use more data but provide better quality. If the exact quality isn't available, the closest option will be selected automatically.\n\nNote: Not all video sources and players support quality selection. This feature works best with HLS streams using the Sora player." + title: String(localized: "Video Quality Preferences"), + footer: String(localized: "Choose preferred video resolution for WiFi and cellular connections. Higher resolutions use more data but provide better quality. If the exact quality isn't available, the closest option will be selected automatically.\n\nNote: Not all video sources and players support quality selection. This feature works best with HLS streams using the Sora player.") ) { SettingsPickerRow( icon: "wifi", - title: "WiFi Quality", + title: String(localized: "WiFi Quality"), options: qualityOptions, optionToString: { $0 }, selection: $wifiQuality @@ -279,7 +278,7 @@ struct SettingsViewPlayer: View { SettingsPickerRow( icon: "antenna.radiowaves.left.and.right", - title: "Cellular Quality", + title: String(localized: "Cellular Quality"), options: qualityOptions, optionToString: { $0 }, selection: $cellularQuality, @@ -287,8 +286,8 @@ struct SettingsViewPlayer: View { ) } - SettingsSection(title: "Progress bar Marker Color") { - ColorPicker("Segments Color", selection: Binding( + SettingsSection(title: NSLocalizedString("Progress bar Marker Color", comment: "")) { + ColorPicker(NSLocalizedString("Segments Color", comment: ""), selection: Binding( get: { if let data = UserDefaults.standard.data(forKey: "segmentsColorData"), let uiColor = try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(data) as? UIColor { @@ -311,12 +310,12 @@ struct SettingsViewPlayer: View { } SettingsSection( - title: "Skip Settings", - footer: "Double tapping the screen on it's sides will skip with the short tap setting." + title: NSLocalizedString("Skip Settings", comment: ""), + footer: NSLocalizedString("Double tapping the screen on it's sides will skip with the short tap setting.", comment: "") ) { SettingsStepperRow( icon: "goforward", - title: "Tap Skip", + title: NSLocalizedString("Tap Skip", comment: ""), value: $skipIncrement, range: 5...300, step: 5, @@ -325,7 +324,7 @@ struct SettingsViewPlayer: View { SettingsStepperRow( icon: "goforward.plus", - title: "Long press Skip", + title: NSLocalizedString("Long press Skip", comment: ""), value: $skipIncrementHold, range: 5...300, step: 5, @@ -334,19 +333,19 @@ struct SettingsViewPlayer: View { SettingsToggleRow( icon: "hand.tap.fill", - title: "Double Tap to Seek", + title: NSLocalizedString("Double Tap to Seek", comment: ""), isOn: $doubleTapSeekEnabled ) SettingsToggleRow( icon: "forward.end", - title: "Show Skip 85s Button", + title: NSLocalizedString("Show Skip 85s Button", comment: ""), isOn: $skip85Visible ) SettingsToggleRow( icon: "forward.frame", - title: "Show Skip Intro / Outro Buttons", + title: NSLocalizedString("Show Skip Intro / Outro Buttons", comment: ""), isOn: $skipIntroOutroVisible, showDivider: false ) @@ -357,7 +356,7 @@ struct SettingsViewPlayer: View { .padding(.vertical, 20) } .scrollViewBottomPadding() - .navigationTitle("Player") + .navigationTitle(NSLocalizedString("Player", comment: "")) } } @@ -374,10 +373,10 @@ struct SubtitleSettingsSection: View { private let shadowOptions = [0, 1, 3, 6] var body: some View { - SettingsSection(title: "Subtitle Settings") { + SettingsSection(title: NSLocalizedString("Subtitle Settings", comment: "")) { SettingsToggleRow( icon: "captions.bubble", - title: "Enable Subtitles", + title: NSLocalizedString("Enable Subtitles", comment: ""), isOn: $subtitlesEnabled, showDivider: false ) @@ -389,7 +388,7 @@ struct SubtitleSettingsSection: View { SettingsPickerRow( icon: "paintbrush", - title: "Subtitle Color", + title: NSLocalizedString("Subtitle Color", comment: ""), options: colors, optionToString: { $0.capitalized }, selection: $foregroundColor @@ -402,7 +401,7 @@ struct SubtitleSettingsSection: View { SettingsPickerRow( icon: "shadow", - title: "Shadow", + title: NSLocalizedString("Shadow", comment: ""), options: shadowOptions, optionToString: { "\($0)" }, selection: Binding( @@ -418,7 +417,7 @@ struct SubtitleSettingsSection: View { SettingsToggleRow( icon: "rectangle.fill", - title: "Background Enabled", + title: NSLocalizedString("Background Enabled", comment: ""), isOn: $backgroundEnabled ) .onChange(of: backgroundEnabled) { newValue in @@ -429,7 +428,7 @@ struct SubtitleSettingsSection: View { SettingsStepperRow( icon: "textformat.size", - title: "Font Size", + title: NSLocalizedString("Font Size", comment: ""), value: $fontSize, range: 12...36, step: 1 @@ -442,7 +441,7 @@ struct SubtitleSettingsSection: View { SettingsStepperRow( icon: "arrow.up.and.down", - title: "Bottom Padding", + title: NSLocalizedString("Bottom Padding", comment: ""), value: $bottomPadding, range: 0...50, step: 1, diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift index b5fae88..2d4ac59 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift @@ -117,7 +117,7 @@ struct SettingsViewTrackers: View { var body: some View { ScrollView { VStack(spacing: 24) { - SettingsSection(title: "AniList") { + SettingsSection(title: NSLocalizedString("AniList", comment: "")) { VStack(spacing: 0) { HStack(alignment: .center, spacing: 10) { LazyImage(url: URL(string: "https://raw.githubusercontent.com/cranci1/Ryu/2f10226aa087154974a70c1ec78aa83a47daced9/Ryu/Assets.xcassets/Listing/Anilist.imageset/anilist.png")) { state in @@ -137,32 +137,30 @@ struct SettingsViewTrackers: View { } VStack(alignment: .leading, spacing: 4) { - Text("AniList.co") + Text(NSLocalizedString("AniList.co", comment: "")) .font(.title3) .fontWeight(.semibold) - Group { - if isAnilistLoading { - ProgressView() - .scaleEffect(0.8) - .frame(height: 18) - } else if isAnilistLoggedIn { - HStack(spacing: 0) { - Text("Logged in as ") - .font(.footnote) - .foregroundStyle(.gray) - Text(anilistUsername) - .font(.footnote) - .fontWeight(.medium) - .foregroundStyle(profileColor) - } + if isAnilistLoading { + ProgressView() + .scaleEffect(0.8) .frame(height: 18) - } else { - Text(anilistStatus) + } else if isAnilistLoggedIn { + HStack(spacing: 0) { + Text(NSLocalizedString("Logged in as", comment: "")) .font(.footnote) .foregroundStyle(.gray) - .frame(height: 18) + Text(anilistUsername) + .font(.footnote) + .fontWeight(.medium) + .foregroundStyle(profileColor) } + .frame(height: 18) + } else { + Text(NSLocalizedString("You are not logged in", comment: "")) + .font(.footnote) + .foregroundStyle(.gray) + .frame(height: 18) } } .frame(height: 60, alignment: .center) @@ -179,7 +177,7 @@ struct SettingsViewTrackers: View { SettingsToggleRow( icon: "arrow.triangle.2.circlepath", - title: "Sync anime progress", + title: NSLocalizedString("Sync anime progress", comment: ""), isOn: $isSendPushUpdates, showDivider: false ) @@ -200,7 +198,7 @@ struct SettingsViewTrackers: View { .frame(width: 24, height: 24) .foregroundStyle(isAnilistLoggedIn ? .red : .accentColor) - Text(isAnilistLoggedIn ? "Log Out from AniList" : "Log In with AniList") + Text(isAnilistLoggedIn ? NSLocalizedString("Log Out from AniList", comment: "") : NSLocalizedString("Log In with AniList", comment: "")) .foregroundStyle(isAnilistLoggedIn ? .red : .accentColor) Spacer() @@ -212,7 +210,7 @@ struct SettingsViewTrackers: View { } } - SettingsSection(title: "Trakt") { + SettingsSection(title: NSLocalizedString("Trakt", comment: "")) { VStack(spacing: 0) { HStack(alignment: .center, spacing: 10) { LazyImage(url: URL(string: "https://static-00.iconduck.com/assets.00/trakt-icon-2048x2048-2633ksxg.png")) { state in @@ -232,32 +230,30 @@ struct SettingsViewTrackers: View { } VStack(alignment: .leading, spacing: 4) { - Text("Trakt.tv") + Text(NSLocalizedString("Trakt.tv", comment: "")) .font(.title3) .fontWeight(.semibold) - Group { - if isTraktLoading { - ProgressView() - .scaleEffect(0.8) - .frame(height: 18) - } else if isTraktLoggedIn { - HStack(spacing: 0) { - Text("Logged in as ") - .font(.footnote) - .foregroundStyle(.gray) - Text(traktUsername) - .font(.footnote) - .fontWeight(.medium) - .foregroundStyle(.primary) - } + if isTraktLoading { + ProgressView() + .scaleEffect(0.8) .frame(height: 18) - } else { - Text(traktStatus) + } else if isTraktLoggedIn { + HStack(spacing: 0) { + Text(NSLocalizedString("Logged in as", comment: "")) .font(.footnote) .foregroundStyle(.gray) - .frame(height: 18) + Text(traktUsername) + .font(.footnote) + .fontWeight(.medium) + .foregroundStyle(Color.accentColor) } + .frame(height: 18) + } else { + Text(NSLocalizedString("You are not logged in", comment: "")) + .font(.footnote) + .foregroundStyle(.gray) + .frame(height: 18) } } .frame(height: 60, alignment: .center) @@ -268,6 +264,18 @@ struct SettingsViewTrackers: View { .padding(.vertical, 12) .frame(height: 84) + if isTraktLoggedIn { + Divider() + .padding(.horizontal, 16) + + SettingsToggleRow( + icon: "arrow.triangle.2.circlepath", + title: NSLocalizedString("Sync TV shows progress", comment: ""), + isOn: $isSendTraktUpdates, + showDivider: false + ) + } + Divider() .padding(.horizontal, 16) @@ -283,7 +291,7 @@ struct SettingsViewTrackers: View { .frame(width: 24, height: 24) .foregroundStyle(isTraktLoggedIn ? .red : .accentColor) - Text(isTraktLoggedIn ? "Log Out from Trakt" : "Log In with Trakt") + Text(isTraktLoggedIn ? NSLocalizedString("Log Out from Trakt", comment: "") : NSLocalizedString("Log In with Trakt", comment: "")) .foregroundStyle(isTraktLoggedIn ? .red : .accentColor) Spacer() @@ -296,14 +304,14 @@ struct SettingsViewTrackers: View { } SettingsSection( - title: "Info", - footer: "Sora and cranci1 are not affiliated with AniList or Trakt in any way.\n\nAlso note that progress updates may not be 100% accurate." + title: NSLocalizedString("Info", comment: ""), + footer: NSLocalizedString("Sora and cranci1 are not affiliated with AniList or Trakt in any way.\n\nAlso note that progress updates may not be 100% accurate.", comment: "") ) {} } .padding(.vertical, 20) } .scrollViewBottomPadding() - .navigationTitle("Trackers") + .navigationTitle(NSLocalizedString("Trackers", comment: "")) .onAppear { updateAniListStatus() updateTraktStatus() diff --git a/Sora/Views/SettingsView/SettingsView.swift b/Sora/Views/SettingsView/SettingsView.swift index 30be2ed..51f191c 100644 --- a/Sora/Views/SettingsView/SettingsView.swift +++ b/Sora/Views/SettingsView/SettingsView.swift @@ -10,13 +10,13 @@ import NukeUI fileprivate struct SettingsNavigationRow: View { let icon: String - let title: String + let titleKey: String let isExternal: Bool let textColor: Color - init(icon: String, title: String, isExternal: Bool = false, textColor: Color = .primary) { + init(icon: String, titleKey: String, isExternal: Bool = false, textColor: Color = .primary) { self.icon = icon - self.title = title + self.titleKey = titleKey self.isExternal = isExternal self.textColor = textColor } @@ -27,7 +27,7 @@ fileprivate struct SettingsNavigationRow: View { .frame(width: 24, height: 24) .foregroundStyle(textColor) - Text(title) + Text(NSLocalizedString(titleKey, comment: "")) .foregroundStyle(textColor) Spacer() @@ -164,22 +164,22 @@ struct SettingsView: View { VStack(spacing: 0) { NavigationLink(destination: SettingsViewGeneral()) { - SettingsNavigationRow(icon: "gearshape", title: "General Settings") + SettingsNavigationRow(icon: "gearshape", titleKey: "General Preferences") } Divider().padding(.horizontal, 16) NavigationLink(destination: SettingsViewPlayer()) { - SettingsNavigationRow(icon: "play.circle", title: "Player Settings") + SettingsNavigationRow(icon: "play.circle", titleKey: "Video Player") } Divider().padding(.horizontal, 16) NavigationLink(destination: SettingsViewDownloads()) { - SettingsNavigationRow(icon: "arrow.down.circle", title: "Download Settings") + SettingsNavigationRow(icon: "arrow.down.circle", titleKey: "Download") } Divider().padding(.horizontal, 16) NavigationLink(destination: SettingsViewTrackers()) { - SettingsNavigationRow(icon: "square.stack.3d.up", title: "Tracking Services") + SettingsNavigationRow(icon: "square.stack.3d.up", titleKey: "Trackers") } } .background(.ultraThinMaterial) @@ -209,12 +209,12 @@ struct SettingsView: View { VStack(spacing: 0) { NavigationLink(destination: SettingsViewData()) { - SettingsNavigationRow(icon: "folder", title: "Data") + SettingsNavigationRow(icon: "folder", titleKey: "Data") } Divider().padding(.horizontal, 16) NavigationLink(destination: SettingsViewLogger()) { - SettingsNavigationRow(icon: "doc.text", title: "Logs") + SettingsNavigationRow(icon: "doc.text", titleKey: "Logs") } } .background(.ultraThinMaterial) @@ -237,21 +237,21 @@ struct SettingsView: View { } VStack(alignment: .leading, spacing: 4) { - Text("INFORMATION") + Text(NSLocalizedString("INFOS", comment: "")) .font(.footnote) .foregroundStyle(.gray) .padding(.horizontal, 20) VStack(spacing: 0) { NavigationLink(destination: SettingsViewAbout()) { - SettingsNavigationRow(icon: "info.circle", title: "About Sora") + SettingsNavigationRow(icon: "info.circle", titleKey: "About Sora") } Divider().padding(.horizontal, 16) Link(destination: URL(string: "https://github.com/cranci1/Sora")!) { SettingsNavigationRow( icon: "chevron.left.forwardslash.chevron.right", - title: "Sora GitHub Repository", + titleKey: "Sora GitHub Repository", isExternal: true, textColor: .gray ) @@ -261,7 +261,7 @@ struct SettingsView: View { Link(destination: URL(string: "https://discord.gg/x7hppDWFDZ")!) { SettingsNavigationRow( icon: "bubble.left.and.bubble.right", - title: "Join Discord Community", + titleKey: "Join the Discord", isExternal: true, textColor: .gray ) @@ -271,7 +271,7 @@ struct SettingsView: View { Link(destination: URL(string: "https://github.com/cranci1/Sora/issues")!) { SettingsNavigationRow( icon: "exclamationmark.circle", - title: "Report an Issue on GitHub", + titleKey: "Report an Issue", isExternal: true, textColor: .gray ) @@ -281,7 +281,7 @@ struct SettingsView: View { Link(destination: URL(string: "https://github.com/cranci1/Sora/blob/dev/LICENSE")!) { SettingsNavigationRow( icon: "doc.text", - title: "License (GPLv3.0)", + titleKey: "License (GPLv3.0)", isExternal: true, textColor: .gray ) @@ -350,6 +350,12 @@ class Settings: ObservableObject { updateAppearance() } } + @Published var selectedLanguage: String { + didSet { + UserDefaults.standard.set(selectedLanguage, forKey: "selectedLanguage") + updateLanguage() + } + } init() { self.accentColor = .primary @@ -359,7 +365,9 @@ class Settings: ObservableObject { } else { self.selectedAppearance = .system } + self.selectedLanguage = UserDefaults.standard.string(forKey: "selectedLanguage") ?? "English" updateAppearance() + updateLanguage() } func updateAccentColor(currentColorScheme: ColorScheme? = nil) { @@ -390,4 +398,10 @@ class Settings: ObservableObject { windowScene.windows.first?.overrideUserInterfaceStyle = .dark } } + + func updateLanguage() { + let languageCode = selectedLanguage == "Dutch" ? "nl" : "en" + UserDefaults.standard.set([languageCode], forKey: "AppleLanguages") + UserDefaults.standard.synchronize() + } } From 525771927c57032896e757b1027142874df648e6 Mon Sep 17 00:00:00 2001 From: 50/50 <80717571+50n50@users.noreply.github.com> Date: Thu, 12 Jun 2025 22:45:56 +0200 Subject: [PATCH 40/52] paul is not liable for any issues (#185) --- Sora/Localizable.xcstrings | 430 ++++++++++++++---- .../SettingsViewDownloads.swift | 38 +- .../SettingsViewGeneral.swift | 87 ++-- Sulfur.xcodeproj/project.pbxproj | 4 +- 4 files changed, 416 insertions(+), 143 deletions(-) diff --git a/Sora/Localizable.xcstrings b/Sora/Localizable.xcstrings index 05a1def..e15ac25 100644 --- a/Sora/Localizable.xcstrings +++ b/Sora/Localizable.xcstrings @@ -170,6 +170,7 @@ } }, "AKA Sulfur" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -226,6 +227,9 @@ } } } + }, + "Also known as Sulfur" : { + }, "AniList" : { "extractionState" : "manual", @@ -243,9 +247,6 @@ } } } - }, - "Also known as Sulfur" : { - }, "AniList ID" : { @@ -541,6 +542,22 @@ } } }, + "Cellular Quality" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Cellular Quality" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Mobiele Kwaliteit" + } + } + } + }, "Check out some community modules here!" : { "localizations" : { "en" : { @@ -557,6 +574,22 @@ } } }, + "Choose preferred video resolution for WiFi and cellular connections. Higher resolutions use more data but provide better quality. If the exact quality isn't available, the closest option will be selected automatically.\n\nNote: Not all video sources and players support quality selection. This feature works best with HLS streams using the Sora player." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Choose preferred video resolution for WiFi and cellular connections. Higher resolutions use more data but provide better quality. If the exact quality isn't available, the closest option will be selected automatically.\n\nNote: Not all video sources and players support quality selection. This feature works best with HLS streams using the Sora player." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Kies de gewenste videoresolutie voor WiFi en mobiele verbindingen. Hogere resoluties gebruiken meer data maar bieden betere kwaliteit. Als de exacte kwaliteit niet beschikbaar is, wordt automatisch de dichtstbijzijnde optie geselecteerd.\n\nLet op: Niet alle videobronnen en spelers ondersteunen kwaliteitsselectie. Deze functie werkt het beste met HLS-streams met de Sora-speler." + } + } + } + }, "Clear" : { "localizations" : { "en" : { @@ -913,6 +946,7 @@ } }, "Download Episode" : { + "extractionState" : "stale", "localizations" : { "nl" : { "stringUnit" : { @@ -924,6 +958,9 @@ }, "Download Summary" : { + }, + "Download This Episode" : { + }, "Downloaded" : { "localizations" : { @@ -996,6 +1033,7 @@ } }, "Enter the AniList ID for this media" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1010,6 +1048,9 @@ } } } + }, + "Enter the AniList ID for this series" : { + }, "Episode %lld" : { "localizations" : { @@ -1129,6 +1170,9 @@ } } } + }, + "Error Fetching Results" : { + }, "Errors and critical issues." : { "extractionState" : "manual", @@ -1179,16 +1223,6 @@ } } }, - "Failed to load contributors" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Failed to load contributors. Please try again later." - } - } - } - }, "Files Downloaded" : { "localizations" : { "en" : { @@ -1451,28 +1485,6 @@ } } }, - "INFOS" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Information" - } - } - } - }, - "Join the Discord" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Join Discord Community" - } - } - } - }, "LESS" : { "extractionState" : "manual", "localizations" : { @@ -1539,16 +1551,6 @@ } } }, - "License (GPLv3.0)" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "View License (GPLv3.0)" - } - } - } - }, "Loading Episode %lld..." : { "localizations" : { "en" : { @@ -1818,8 +1820,12 @@ } } } + }, + "MAIN SETTINGS" : { + }, "Mark All Previous Watched" : { + "extractionState" : "stale", "localizations" : { "nl" : { "stringUnit" : { @@ -1838,6 +1844,12 @@ } } } + }, + "Mark Episode as Watched" : { + + }, + "Mark Previous Episodes as Watched" : { + }, "Mark watched" : { "localizations" : { @@ -1849,23 +1861,17 @@ } } }, - "Mark Previous Episodes as Watched" : { - - }, - "Mark watched" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mark as Watched" - } - } - } - }, "Match with AniList" : { + }, + "Match with TMDB" : { + + }, + "Matched ID: %lld" : { + }, "Matched with: %@" : { + "extractionState" : "stale", "localizations" : { "nl" : { "stringUnit" : { @@ -1950,6 +1956,7 @@ } }, "Metadata Provider" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1964,6 +1971,9 @@ } } } + }, + "Metadata Providers Order" : { + }, "Module Removed" : { "extractionState" : "manual", @@ -2103,6 +2113,7 @@ } }, "No items to continue watching." : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2167,6 +2178,7 @@ } }, "No Results Found" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2181,6 +2193,9 @@ } } } + }, + "No Search Results Found" : { + }, "Note that the modules will be replaced only if there is a different version string inside the JSON file." : { "localizations" : { @@ -2197,6 +2212,9 @@ } } } + }, + "Nothing to Continue Watching" : { + }, "OK" : { "localizations" : { @@ -2360,6 +2378,9 @@ } } } + }, + "Provider: %@" : { + }, "Queue" : { @@ -2381,6 +2402,7 @@ } }, "Recently watched content will appear here." : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2592,17 +2614,6 @@ } } }, - "Report an Issue" : { - "extractionState" : "stale", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Report an Issue on GitHub" - } - } - } - }, "Reset" : { "localizations" : { "en" : { @@ -2656,6 +2667,7 @@ } }, "Reset Progress" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2688,6 +2700,7 @@ } }, "Running Sora %@ - cranci1" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2987,6 +3000,9 @@ }, "Sora" : { + }, + "Sora %@ by cranci1" : { + }, "Sora and cranci1 are not affiliated with AniList or Trakt in any way.\n\nAlso note that progress updates may not be 100% accurate." : { "extractionState" : "manual", @@ -3023,6 +3039,7 @@ } }, "Sora/Sulfur will always remain free with no ADs!" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3351,6 +3368,7 @@ } }, "Thumbnails Width" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3406,6 +3424,7 @@ }, "Try different keywords" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -3420,6 +3439,9 @@ } } } + }, + "Try different search terms" : { + }, "Two Finger Hold for Pause" : { "localizations" : { @@ -3436,6 +3458,9 @@ } } } + }, + "Unable to fetch matches. Please try again later." : { + }, "Use TMDB Poster Image" : { "localizations" : { @@ -3472,6 +3497,22 @@ } } }, + "Video Quality Preferences" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Video Quality Preferences" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Video Kwaliteit Voorkeuren" + } + } + } + }, "View All" : { "localizations" : { "en" : { @@ -3521,6 +3562,22 @@ } } }, + "WiFi Quality" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "WiFi Quality" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "WiFi Kwaliteit" + } + } + } + }, "You are not logged in" : { "extractionState" : "manual", "localizations" : { @@ -3570,66 +3627,277 @@ } } }, - "Video Quality Preferences" : { + "Your recently watched content will appear here" : { + + }, + "Download Settings" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Video Quality Preferences" + "value" : "Download Settings" } }, "nl" : { "stringUnit" : { "state" : "new", - "value" : "Video Kwaliteit Voorkeuren" + "value" : "Download Instellingen" } } } }, - "Choose preferred video resolution for WiFi and cellular connections. Higher resolutions use more data but provide better quality. If the exact quality isn't available, the closest option will be selected automatically.\n\nNote: Not all video sources and players support quality selection. This feature works best with HLS streams using the Sora player." : { + "Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources." : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Choose preferred video resolution for WiFi and cellular connections. Higher resolutions use more data but provide better quality. If the exact quality isn't available, the closest option will be selected automatically.\n\nNote: Not all video sources and players support quality selection. This feature works best with HLS streams using the Sora player." + "value" : "Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources." } }, "nl" : { "stringUnit" : { "state" : "new", - "value" : "Kies de gewenste videoresolutie voor WiFi en mobiele verbindingen. Hogere resoluties gebruiken meer data maar bieden betere kwaliteit. Als de exacte kwaliteit niet beschikbaar is, wordt automatisch de dichtstbijzijnde optie geselecteerd.\n\nLet op: Niet alle videobronnen en spelers ondersteunen kwaliteitsselectie. Deze functie werkt het beste met HLS-streams met de Sora-speler." + "value" : "Maximum gelijktijdige downloads bepaalt hoeveel afleveringen tegelijk kunnen worden gedownload. Hogere waarden kunnen meer bandbreedte en apparaatbronnen gebruiken." } } } }, - "WiFi Quality" : { + "Quality" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "WiFi Quality" + "value" : "Quality" } }, "nl" : { "stringUnit" : { "state" : "new", - "value" : "WiFi Kwaliteit" + "value" : "Kwaliteit" } } } }, - "Cellular Quality" : { + "Max Concurrent Downloads" : { "localizations" : { "en" : { "stringUnit" : { "state" : "new", - "value" : "Cellular Quality" + "value" : "Max Concurrent Downloads" } }, "nl" : { "stringUnit" : { "state" : "new", - "value" : "Mobiele Kwaliteit" + "value" : "Maximum Gelijktijdige Downloads" + } + } + } + }, + "Allow Cellular Downloads" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Allow Cellular Downloads" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Downloads via Mobiel Netwerk Toestaan" + } + } + } + }, + "Quality Information" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Quality Information" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Kwaliteitsinformatie" + } + } + } + }, + "Storage Management" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Storage Management" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Opslagbeheer" + } + } + } + }, + "Storage Used" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Storage Used" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Gebruikte Opslag" + } + } + } + }, + "Files Downloaded" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Files Downloaded" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Gedownloade Bestanden" + } + } + } + }, + "Refresh Storage Info" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Refresh Storage Info" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Opslaginformatie Vernieuwen" + } + } + } + }, + "Clear All Downloads" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear All Downloads" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Alle Downloads Wissen" + } + } + } + }, + "Delete All Downloads" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Delete All Downloads" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Alle Downloads Verwijderen" + } + } + } + }, + "Are you sure you want to delete all downloaded assets? You can choose to clear only the library while preserving the downloaded files for future use." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Are you sure you want to delete all downloaded assets? You can choose to clear only the library while preserving the downloaded files for future use." + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Weet je zeker dat je alle gedownloade bestanden wilt verwijderen? Je kunt ervoor kiezen om alleen de bibliotheek te wissen terwijl je de gedownloade bestanden voor later gebruik bewaart." + } + } + } + }, + "Clear Library Only" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Clear Library Only" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Alleen Bibliotheek Wissen" + } + } + } + }, + "Library cleared successfully" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Library cleared successfully" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Bibliotheek succesvol gewist" + } + } + } + }, + "All downloads deleted successfully" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "All downloads deleted successfully" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Alle downloads succesvol verwijderd" + } + } + } + }, + "Downloads" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Downloads" + } + }, + "nl" : { + "stringUnit" : { + "state" : "new", + "value" : "Downloads" } } } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewDownloads.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewDownloads.swift index 587d55c..ab9be27 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewDownloads.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewDownloads.swift @@ -164,12 +164,12 @@ struct SettingsViewDownloads: View { ScrollView { VStack(spacing: 24) { SettingsSection( - title: "Download Settings", - footer: "Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources." + title: String(localized: "Download Settings"), + footer: String(localized: "Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources.") ) { SettingsPickerRow( icon: "4k.tv", - title: "Quality", + title: String(localized: "Quality"), options: DownloadQualityPreference.allCases.map { $0.rawValue }, optionToString: { $0 }, selection: $downloadQuality @@ -181,7 +181,7 @@ struct SettingsViewDownloads: View { .frame(width: 24, height: 24) .foregroundStyle(.primary) - Text("Max Concurrent Downloads") + Text(String(localized: "Max Concurrent Downloads")) .foregroundStyle(.primary) Spacer() @@ -200,14 +200,14 @@ struct SettingsViewDownloads: View { SettingsToggleRow( icon: "antenna.radiowaves.left.and.right", - title: "Allow Cellular Downloads", + title: String(localized: "Allow Cellular Downloads"), isOn: $allowCellularDownloads, showDivider: false ) } SettingsSection( - title: "Quality Information" + title: String(localized: "Quality Information") ) { if let preferenceDescription = DownloadQualityPreference(rawValue: downloadQuality)?.description { HStack { @@ -222,7 +222,7 @@ struct SettingsViewDownloads: View { } SettingsSection( - title: "Storage Management" + title: String(localized: "Storage Management") ) { VStack(spacing: 0) { HStack { @@ -230,7 +230,7 @@ struct SettingsViewDownloads: View { .frame(width: 24, height: 24) .foregroundStyle(.primary) - Text("Storage Used") + Text(String(localized: "Storage Used")) .foregroundStyle(.primary) Spacer() @@ -255,7 +255,7 @@ struct SettingsViewDownloads: View { .frame(width: 24, height: 24) .foregroundStyle(.primary) - Text("Files Downloaded") + Text(String(localized: "Files Downloaded")) .foregroundStyle(.primary) Spacer() @@ -277,7 +277,7 @@ struct SettingsViewDownloads: View { .frame(width: 24, height: 24) .foregroundStyle(.primary) - Text("Refresh Storage Info") + Text(String(localized: "Refresh Storage Info")) .foregroundStyle(.primary) Spacer() @@ -297,7 +297,7 @@ struct SettingsViewDownloads: View { .frame(width: 24, height: 24) .foregroundStyle(.red) - Text("Clear All Downloads") + Text(String(localized: "Clear All Downloads")) .foregroundStyle(.red) Spacer() @@ -310,18 +310,18 @@ struct SettingsViewDownloads: View { } .padding(.vertical, 20) } - .navigationTitle("Downloads") + .navigationTitle(String(localized: "Downloads")) .scrollViewBottomPadding() - .alert("Delete All Downloads", isPresented: $showClearConfirmation) { - Button("Cancel", role: .cancel) { } - Button("Delete All", role: .destructive) { + .alert(String(localized: "Delete All Downloads"), isPresented: $showClearConfirmation) { + Button(String(localized: "Cancel"), role: .cancel) { } + Button(String(localized: "Delete All"), role: .destructive) { clearAllDownloads(preservePersistentDownloads: false) } - Button("Clear Library Only", role: .destructive) { + Button(String(localized: "Clear Library Only"), role: .destructive) { clearAllDownloads(preservePersistentDownloads: true) } } message: { - Text("Are you sure you want to delete all downloaded assets? You can choose to clear only the library while preserving the downloaded files for future use.") + Text(String(localized: "Are you sure you want to delete all downloaded assets? You can choose to clear only the library while preserving the downloaded files for future use.")) } .onAppear { calculateTotalStorage() @@ -370,9 +370,9 @@ struct SettingsViewDownloads: View { DispatchQueue.main.async { if preservePersistentDownloads { - DropManager.shared.success("Library cleared successfully") + DropManager.shared.success(String(localized: "Library cleared successfully")) } else { - DropManager.shared.success("All downloads deleted successfully") + DropManager.shared.success(String(localized: "All downloads deleted successfully")) } } } diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift index 7663138..1c48ac8 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift @@ -160,6 +160,7 @@ struct SettingsViewGeneral: View { @AppStorage("tmdbImageWidth") private var TMDBimageWidht: String = "original" @AppStorage("mediaColumnsPortrait") private var mediaColumnsPortrait: Int = 2 @AppStorage("mediaColumnsLandscape") private var mediaColumnsLandscape: Int = 4 + @AppStorage("metadataProviders") private var metadataProviders: String = "TMDB" private var metadataProvidersOrder: [String] { get { (try? JSONDecoder().decode([String].self, from: metadataProvidersOrderData)) ?? ["AniList","TMDB"] } @@ -167,6 +168,7 @@ struct SettingsViewGeneral: View { } private let TMDBimageWidhtList = ["300", "500", "780", "1280", "original"] private let sortOrderOptions = ["Ascending", "Descending"] + private let metadataProvidersList = ["TMDB", "AniList"] @EnvironmentObject var settings: Settings @State private var showRestartAlert = false @@ -227,51 +229,54 @@ struct SettingsViewGeneral: View { isOn: $fetchEpisodeMetadata ) - List { - ForEach(metadataProvidersOrder, id: \.self) { prov in - Text(prov) - .padding(.vertical, 8) + VStack(spacing: 0) { + HStack { + Image(systemName: "arrow.up.arrow.down") + .frame(width: 24, height: 24) + .foregroundStyle(.primary) + + Text(NSLocalizedString("Metadata Providers Order", comment: "")) + .foregroundStyle(.primary) + + Spacer() } - .onMove { idx, dest in - var arr = metadataProvidersOrder - arr.move(fromOffsets: idx, toOffset: dest) - metadataProvidersOrderData = try! JSONEncoder().encode(arr) + .padding(.horizontal, 16) + .padding(.vertical, 12) + + Divider() + .padding(.horizontal, 16) + + List { + ForEach(Array(metadataProvidersOrder.enumerated()), id: \.element) { index, provider in + HStack { + Text("\(index + 1)") + .frame(width: 24, height: 24) + .foregroundStyle(.gray) + + Text(provider) + .foregroundStyle(.primary) + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .listRowBackground(Color.clear) + .listRowSeparator(.visible) + .listRowSeparatorTint(.gray.opacity(0.3)) + .listRowInsets(EdgeInsets()) + } + .onMove { from, to in + var arr = metadataProvidersOrder + arr.move(fromOffsets: from, toOffset: to) + metadataProvidersOrderData = try! JSONEncoder().encode(arr) + } } + .listStyle(.plain) + .frame(height: CGFloat(metadataProvidersOrder.count * 48)) + .background(Color.clear) + .padding(.bottom, 8) } .environment(\.editMode, .constant(.active)) - .frame(height: 140) - - SettingsSection( - title: "Media Grid Layout", - footer: "Adjust the number of media items per row in portrait and landscape modes." - ) { - SettingsPickerRow( - icon: "server.rack", - title: NSLocalizedString("Metadata Provider", comment: ""), - options: metadataProvidersList, - optionToString: { $0 }, - selection: $metadataProviders, - showDivider: true - ) - - SettingsPickerRow( - icon: "square.stack.3d.down.right", - title: NSLocalizedString("Thumbnails Width", comment: ""), - options: TMDBimageWidhtList, - optionToString: { $0 }, - selection: $TMDBimageWidht, - showDivider: false - ) - } else { - SettingsPickerRow( - icon: "server.rack", - title: NSLocalizedString("Metadata Provider", comment: ""), - options: metadataProvidersList, - optionToString: { $0 }, - selection: $metadataProviders, - showDivider: false - ) - } } SettingsSection( diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index ac6ba3b..3f2c08b 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -944,7 +944,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Sora/Preview Content\""; - DEVELOPMENT_TEAM = 385Y24WAN5; + DEVELOPMENT_TEAM = 399LMK6Q2Y; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -986,7 +986,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Sora/Preview Content\""; - DEVELOPMENT_TEAM = 385Y24WAN5; + DEVELOPMENT_TEAM = 399LMK6Q2Y; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = NO; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; From 706a448698953cf334ec7bd57f2e063092aadbd0 Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Fri, 13 Jun 2025 09:08:03 +0200 Subject: [PATCH 41/52] removed double bs for id telling (#186) --- Sora/Views/MediaInfoView/MediaInfoView.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 0fdc24c..d280b32 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -689,10 +689,6 @@ struct MediaInfoView: View { Button("Match with AniList") { isMatchingPresented = true } - Text("Matched ID: \(itemID ?? 0)") - .font(.caption2) - .foregroundColor(.secondary) - Button(action: { resetAniListID() }) { Label("Reset AniList ID", systemImage: "arrow.clockwise") } From 02314a75e33f57cd8c01c2767d479cccbe854b44 Mon Sep 17 00:00:00 2001 From: 50/50 <80717571+50n50@users.noreply.github.com> Date: Fri, 13 Jun 2025 09:08:21 +0200 Subject: [PATCH 42/52] bundle ID fix (sowy) (#187) * Fixed ALL view strings + Dutch translations I THINK I fixed all view strings, i went through all so idk * Fix * Fixed type shi * List number + small text fixes * Update project.pbxproj --- Sulfur.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 3f2c08b..1fe17f0 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -962,7 +962,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur1; + PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1004,7 +1004,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0.0; - PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur1; + PRODUCT_BUNDLE_IDENTIFIER = me.cranci.sulfur; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; From 473759085e260c4c5726e748647caadb3770748c Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Fri, 13 Jun 2025 09:33:37 +0200 Subject: [PATCH 43/52] test --- README.md | 1 + Sora/Views/MediaInfoView/MediaInfoView.swift | 90 +++++++++++--------- Sulfur.xcodeproj/project.pbxproj | 2 +- 3 files changed, 50 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index b3cda4b..3d358db 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ Additionally, you can install the app using Xcode or using the .ipa file, which Frameworks: - [Drops](https://github.com/omaralbeik/Drops) - MIT License - [NukeUI](https://github.com/kean/NukeUI) - MIT License +- [SoraCore](https://github.com/cranci1/SoraCore) - Custom License - [MarqueeLabel](https://github.com/cbpowell/MarqueeLabel) - MIT License Misc: diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index d280b32..e0d57d9 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -92,8 +92,8 @@ struct MediaInfoView: View { }() private var metadataProvidersOrder: [String] { - get { (try? JSONDecoder().decode([String].self, from: metadataProvidersOrderData)) ?? ["AniList","TMDB"] } - set { metadataProvidersOrderData = try! JSONEncoder().encode(newValue) } + get { (try? JSONDecoder().decode([String].self, from: metadataProvidersOrderData)) ?? ["AniList","TMDB"] } + set { metadataProvidersOrderData = try! JSONEncoder().encode(newValue) } } private var isGroupedBySeasons: Bool { @@ -657,7 +657,7 @@ struct MediaInfoView: View { .sheet(isPresented: $isMatchingPresented) { AnilistMatchPopupView(seriesTitle: title) { selectedID in handleAniListMatch(selectedID: selectedID) - fetchMetadataIDIfNeeded() // ← use your new async re-try loop + fetchMetadataIDIfNeeded() } } .sheet(isPresented: $isTMDBMatchingPresented) { @@ -671,7 +671,6 @@ struct MediaInfoView: View { @ViewBuilder private var menuContent: some View { Group { - // Show which provider “won” if let active = activeProvider { Text("Provider: \(active)") .font(.caption) @@ -680,7 +679,6 @@ struct MediaInfoView: View { Divider() } - Text("Matched ID: \(itemID ?? 0)") .font(.caption2) .foregroundColor(.secondary) @@ -697,14 +695,12 @@ struct MediaInfoView: View { Label("Open in AniList", systemImage: "link") } } - // TMDB branch: only match else if activeProvider == "TMDB" { Button("Match with TMDB") { isTMDBMatchingPresented = true } } - // Keep all of your existing poster & debug options posterMenuOptions Divider() @@ -714,7 +710,6 @@ struct MediaInfoView: View { } } } - @ViewBuilder private var posterMenuOptions: some View { @@ -1189,58 +1184,69 @@ struct MediaInfoView: View { private func fetchMetadataIDIfNeeded() { let order = metadataProvidersOrder let cleanedTitle = cleanTitle(title) - + itemID = nil tmdbID = nil activeProvider = nil isError = false - - fetchItemID(byTitle: cleanedTitle) { result in - switch result { - case .success(let id): - DispatchQueue.main.async { self.itemID = id } - case .failure(let error): - Logger.shared.log("Failed to fetch AniList ID for tracking: \(error)", type: "Error") + + func fetchAniList(completion: @escaping (Bool) -> Void) { + fetchItemID(byTitle: cleanedTitle) { result in + switch result { + case .success(let id): + DispatchQueue.main.async { + self.itemID = id + self.activeProvider = "AniList" + UserDefaults.standard.set("AniList", forKey: "metadataProviders") + completion(true) + } + case .failure(let error): + Logger.shared.log("Failed to fetch AniList ID for tracking: \(error)", type: "Error") + completion(false) + } } } - - func tryNext(_ index: Int) { + + func fetchTMDB(completion: @escaping (Bool) -> Void) { + tmdbFetcher.fetchBestMatchID(for: cleanedTitle) { id, type in + DispatchQueue.main.async { + if let id = id, let type = type { + self.tmdbID = id + self.tmdbType = type + self.activeProvider = "TMDB" + UserDefaults.standard.set("TMDB", forKey: "metadataProviders") + completion(true) + } else { + completion(false) + } + } + } + } + + func tryProviders(_ index: Int) { guard index < order.count else { isError = true return } let provider = order[index] - if provider == "TMDB" { - tmdbFetcher.fetchBestMatchID(for: cleanedTitle) { id, type in - DispatchQueue.main.async { - if let id = id, let type = type { - self.tmdbID = id - self.tmdbType = type - self.activeProvider = "TMDB" - UserDefaults.standard.set("TMDB", forKey: "metadataProviders") - } else { - tryNext(index + 1) - } + if provider == "AniList" { + fetchAniList { success in + if !success { + tryProviders(index + 1) } } - } else if provider == "AniList" { - fetchItemID(byTitle: cleanedTitle) { result in - switch result { - case .success: - DispatchQueue.main.async { - self.activeProvider = "AniList" - UserDefaults.standard.set("AniList", forKey: "metadataProviders") - } - case .failure: - tryNext(index + 1) + } else if provider == "TMDB" { + fetchTMDB { success in + if !success { + tryProviders(index + 1) } } } else { - tryNext(index + 1) + tryProviders(index + 1) } } - - tryNext(0) + + tryProviders(0) } private func fetchItemID(byTitle title: String, completion: @escaping (Result) -> Void) { diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 1fe17f0..17f705a 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -370,8 +370,8 @@ 133D7C7F2D2BE2630075467E /* MediaInfoView */ = { isa = PBXGroup; children = ( - 1EDA48F32DFAC374002A4EC3 /* TMDBMatchPopupView.swift */, 138AA1B52D2D66EC0021F9DF /* EpisodeCell */, + 1EDA48F32DFAC374002A4EC3 /* TMDBMatchPopupView.swift */, 133D7C802D2BE2630075467E /* MediaInfoView.swift */, 1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */, ); From c48dbbe3cb60c210ab08b804d2769f6f97cedd9a Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Fri, 13 Jun 2025 17:11:03 +0200 Subject: [PATCH 44/52] little tweaks --- Sora/Utils/Extensions/URLSession.swift | 49 +++++++++---------- .../SettingsSubViews/SettingsViewAbout.swift | 21 +++++++- 2 files changed, 42 insertions(+), 28 deletions(-) diff --git a/Sora/Utils/Extensions/URLSession.swift b/Sora/Utils/Extensions/URLSession.swift index 6df9f8e..d1cacb7 100644 --- a/Sora/Utils/Extensions/URLSession.swift +++ b/Sora/Utils/Extensions/URLSession.swift @@ -28,29 +28,29 @@ class FetchDelegate: NSObject, URLSessionTaskDelegate { extension URLSession { static let userAgents = [ + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36 Edg/129.0.2569.45", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.2478.89", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.2903.86", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.2849.80", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.2 Safari/605.1.15", "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_1_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.1 Safari/605.1.15", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 15_0_2) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.0 Safari/605.1.15", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 15.1; rv:128.0) Gecko/20100101 Firefox/128.0", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 15.0; rv:127.0) Gecko/20100101 Firefox/127.0", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", - "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0", - "Mozilla/5.0 (X11; Linux x86_64; rv:127.0) Gecko/20100101 Firefox/127.0", - "Mozilla/5.0 (Linux; Android 15; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6782.112 Mobile Safari/537.36", - "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.6782.112 Mobile Safari/537.36", - "Mozilla/5.0 (iPhone; CPU iPhone OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.3 Mobile/15E148 Safari/604.1", - "Mozilla/5.0 (iPad; CPU OS 18_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.3 Mobile/15E148 Safari/604.1", - "Mozilla/5.0 (Android 15; Mobile; rv:128.0) Gecko/128.0 Firefox/128.0", - "Mozilla/5.0 (Android 15; Mobile; rv:127.0) Gecko/127.0 Firefox/127.0" + "Mozilla/5.0 (Macintosh; Intel Mac OS X 15.1; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (Macintosh; Intel Mac OS X 15.0; rv:131.0) Gecko/20100101 Firefox/131.0", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + "Mozilla/5.0 (X11; Linux x86_64; rv:132.0) Gecko/20100101 Firefox/132.0", + "Mozilla/5.0 (X11; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0", + "Mozilla/5.0 (Linux; Android 15; SM-S928B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.135 Mobile Safari/537.36", + "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.6778.135 Mobile Safari/537.36", + "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (iPad; CPU OS 18_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/19.2 Mobile/15E148 Safari/604.1", + "Mozilla/5.0 (Android 15; Mobile; rv:132.0) Gecko/132.0 Firefox/132.0", + "Mozilla/5.0 (Android 14; Mobile; rv:131.0) Gecko/131.0 Firefox/131.0" ] static var randomUserAgent: String = { @@ -78,7 +78,6 @@ enum NetworkType { case unknown } -@available(iOS 14.0, *) class NetworkMonitor: ObservableObject { static let shared = NetworkMonitor() @@ -113,11 +112,7 @@ class NetworkMonitor: ObservableObject { } static func getCurrentNetworkType() -> NetworkType { - if #available(iOS 14.0, *) { - return shared.currentNetworkType - } else { - return .unknown - } + return shared.currentNetworkType } deinit { diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift index 01b7bea..9a909ae 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewAbout.swift @@ -172,9 +172,28 @@ struct ContributorsView: View { } private var filteredContributors: [Contributor] { - contributors.filter { contributor in + let realContributors = contributors.filter { contributor in !["cranci1", "code-factor"].contains(contributor.login.lowercased()) } + + let artificialUsers = createArtificialUsers() + + return realContributors + artificialUsers + } + + private func createArtificialUsers() -> [Contributor] { + return [ + Contributor( + id: 71751652, + login: "qooode", + avatarUrl: "https://avatars.githubusercontent.com/u/71751652?v=4" + ), + Contributor( + id: 8116188, + login: "undeaDD", + avatarUrl: "https://avatars.githubusercontent.com/u/8116188?v=4" + ) + ] } private func loadContributors() { From b03ff287fed7efeb697ba617d6cffb24f3688f7e Mon Sep 17 00:00:00 2001 From: realdoomsboygaming <105606471+realdoomsboygaming@users.noreply.github.com> Date: Fri, 13 Jun 2025 08:11:49 -0700 Subject: [PATCH 45/52] Fix downloads of modules that state they are HLS but might return mp4 streams + Consolidated download methods into 1 file (#188) * Add MP4 Fallback redirect if stream URL is MP4 for some reason * Consolidate Download Method * Clean up download logic * Further Cleanup * Fix Logging Oopsie --- .../Downloads/JSController+Downloader.swift | 527 ++++++++++++++++++ .../Downloads/JSController+M3U8Download.swift | 384 ------------- .../Downloads/JSController+MP4Download.swift | 158 ------ Sulfur.xcodeproj/project.pbxproj | 12 +- 4 files changed, 531 insertions(+), 550 deletions(-) create mode 100644 Sora/Utils/JSLoader/Downloads/JSController+Downloader.swift delete mode 100644 Sora/Utils/JSLoader/Downloads/JSController+M3U8Download.swift delete mode 100644 Sora/Utils/JSLoader/Downloads/JSController+MP4Download.swift diff --git a/Sora/Utils/JSLoader/Downloads/JSController+Downloader.swift b/Sora/Utils/JSLoader/Downloads/JSController+Downloader.swift new file mode 100644 index 0000000..621152c --- /dev/null +++ b/Sora/Utils/JSLoader/Downloads/JSController+Downloader.swift @@ -0,0 +1,527 @@ +// +// JSController+Downloader.swift +// Sora +// +// Created by doomsboygaming on 6/13/25 +// + +import Foundation +import SwiftUI +import AVFoundation + + +struct DownloadRequest { + let url: URL + let headers: [String: String] + let title: String? + let imageURL: URL? + let isEpisode: Bool + let showTitle: String? + let season: Int? + let episode: Int? + let subtitleURL: URL? + let showPosterURL: URL? + + init(url: URL, headers: [String: String], title: String? = nil, imageURL: URL? = nil, + isEpisode: Bool = false, showTitle: String? = nil, season: Int? = nil, + episode: Int? = nil, subtitleURL: URL? = nil, showPosterURL: URL? = nil) { + self.url = url + self.headers = headers + self.title = title + self.imageURL = imageURL + self.isEpisode = isEpisode + self.showTitle = showTitle + self.season = season + self.episode = episode + self.subtitleURL = subtitleURL + self.showPosterURL = showPosterURL + } +} + +struct QualityOption { + let name: String + let url: String + let height: Int? + + init(name: String, url: String, height: Int? = nil) { + self.name = name + self.url = url + self.height = height + } +} + +extension JSController { + + func downloadWithM3U8Support(url: URL, headers: [String: String], title: String? = nil, + imageURL: URL? = nil, isEpisode: Bool = false, + showTitle: String? = nil, season: Int? = nil, episode: Int? = nil, + subtitleURL: URL? = nil, showPosterURL: URL? = nil, + completionHandler: ((Bool, String) -> Void)? = nil) { + + let request = DownloadRequest( + url: url, headers: headers, title: title, imageURL: imageURL, + isEpisode: isEpisode, showTitle: showTitle, season: season, + episode: episode, subtitleURL: subtitleURL, showPosterURL: showPosterURL + ) + + logDownloadStart(request: request) + + if url.absoluteString.contains(".m3u8") { + handleM3U8Download(request: request, completionHandler: completionHandler) + } else { + handleDirectDownload(request: request, completionHandler: completionHandler) + } + } + + + private func handleM3U8Download(request: DownloadRequest, completionHandler: ((Bool, String) -> Void)?) { + let preferredQuality = DownloadQualityPreference.current.rawValue + logM3U8Detection(preferredQuality: preferredQuality) + + parseM3U8(url: request.url, headers: request.headers) { [weak self] qualities in + DispatchQueue.main.async { + guard let self = self else { return } + + if qualities.isEmpty { + self.logM3U8NoQualities() + self.downloadWithOriginalMethod(request: request, completionHandler: completionHandler) + return + } + + self.logM3U8QualitiesFound(qualities: qualities) + let selectedQuality = self.selectQualityBasedOnPreference(qualities: qualities, preferredQuality: preferredQuality) + self.logM3U8QualitySelected(quality: selectedQuality) + + if let qualityURL = URL(string: selectedQuality.url) { + let qualityRequest = DownloadRequest( + url: qualityURL, headers: request.headers, title: request.title, + imageURL: request.imageURL, isEpisode: request.isEpisode, + showTitle: request.showTitle, season: request.season, + episode: request.episode, subtitleURL: request.subtitleURL, + showPosterURL: request.showPosterURL + ) + self.downloadWithOriginalMethod(request: qualityRequest, completionHandler: completionHandler) + } else { + self.logM3U8InvalidURL() + self.downloadWithOriginalMethod(request: request, completionHandler: completionHandler) + } + } + } + } + + private func handleDirectDownload(request: DownloadRequest, completionHandler: ((Bool, String) -> Void)?) { + logDirectDownload() + + let urlString = request.url.absoluteString.lowercased() + if urlString.contains(".mp4") || urlString.contains("mp4") { + logMP4Detection() + downloadMP4(request: request, completionHandler: completionHandler) + } else { + downloadWithOriginalMethod(request: request, completionHandler: completionHandler) + } + } + + + func downloadMP4(url: URL, headers: [String: String], title: String? = nil, + imageURL: URL? = nil, isEpisode: Bool = false, + showTitle: String? = nil, season: Int? = nil, episode: Int? = nil, + subtitleURL: URL? = nil, showPosterURL: URL? = nil, + completionHandler: ((Bool, String) -> Void)? = nil) { + + let request = DownloadRequest( + url: url, headers: headers, title: title, imageURL: imageURL, + isEpisode: isEpisode, showTitle: showTitle, season: season, + episode: episode, subtitleURL: subtitleURL, showPosterURL: showPosterURL + ) + + downloadMP4(request: request, completionHandler: completionHandler) + } + + private func downloadMP4(request: DownloadRequest, completionHandler: ((Bool, String) -> Void)?) { + guard validateURL(request.url) else { + completionHandler?(false, "Invalid URL scheme") + return + } + + guard let downloadSession = downloadURLSession else { + completionHandler?(false, "Download session not available") + return + } + + let metadata = createAssetMetadata(from: request) + let downloadType: DownloadType = request.isEpisode ? .episode : .movie + let downloadID = UUID() + + let asset = AVURLAsset(url: request.url, options: [ + "AVURLAssetHTTPHeaderFieldsKey": request.headers + ]) + + guard let downloadTask = downloadSession.makeAssetDownloadTask( + asset: asset, + assetTitle: request.title ?? request.url.lastPathComponent, + assetArtworkData: nil, + options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 2_000_000] + ) else { + completionHandler?(false, "Failed to create download task") + return + } + + let activeDownload = createActiveDownload( + id: downloadID, request: request, asset: asset, + downloadTask: downloadTask, downloadType: downloadType, metadata: metadata + ) + + addActiveDownload(activeDownload, task: downloadTask) + setupMP4ProgressObservation(for: downloadTask, downloadID: downloadID) + downloadTask.resume() + + postDownloadNotification() + completionHandler?(true, "Download started") + } + + + private func parseM3U8(url: URL, headers: [String: String], completion: @escaping ([QualityOption]) -> Void) { + var request = URLRequest(url: url) + for (key, value) in headers { + request.addValue(value, forHTTPHeaderField: key) + } + + logM3U8FetchStart(url: url) + + URLSession.shared.dataTask(with: request) { data, response, error in + if let httpResponse = response as? HTTPURLResponse { + self.logHTTPStatus(httpResponse.statusCode, for: url) + if httpResponse.statusCode >= 400 { + completion([]) + return + } + } + + if let error = error { + self.logM3U8FetchError(error) + completion([]) + return + } + + guard let data = data, let content = String(data: data, encoding: .utf8) else { + self.logM3U8DecodeError() + completion([]) + return + } + + self.logM3U8FetchSuccess(dataSize: data.count) + let qualities = self.parseM3U8Content(content: content, baseURL: url) + completion(qualities) + }.resume() + } + + private func parseM3U8Content(content: String, baseURL: URL) -> [QualityOption] { + let lines = content.components(separatedBy: .newlines) + logM3U8ParseStart(lineCount: lines.count) + + var qualities: [QualityOption] = [] + qualities.append(QualityOption(name: "Auto (Recommended)", url: baseURL.absoluteString)) + + for (index, line) in lines.enumerated() { + if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count { + if let qualityOption = parseStreamInfoLine(line: line, nextLine: lines[index + 1], baseURL: baseURL) { + if !qualities.contains(where: { $0.name == qualityOption.name }) { + qualities.append(qualityOption) + logM3U8QualityAdded(quality: qualityOption) + } + } + } + } + + logM3U8ParseComplete(qualityCount: qualities.count - 1) // -1 for Auto + return qualities + } + + private func parseStreamInfoLine(line: String, nextLine: String, baseURL: URL) -> QualityOption? { + guard let resolutionRange = line.range(of: "RESOLUTION="), + let resolutionEndRange = line[resolutionRange.upperBound...].range(of: ",") + ?? line[resolutionRange.upperBound...].range(of: "\n") else { + return nil + } + + let resolutionPart = String(line[resolutionRange.upperBound.. String { + switch height { + case 1080...: return "\(height)p (FHD)" + case 720..<1080: return "\(height)p (HD)" + case 480..<720: return "\(height)p (SD)" + default: return "\(height)p" + } + } + + private func resolveQualityURL(_ urlString: String, baseURL: URL) -> String { + if urlString.hasPrefix("http") { + return urlString + } + + if urlString.contains(".m3u8") { + return URL(string: urlString, relativeTo: baseURL)?.absoluteString + ?? baseURL.deletingLastPathComponent().absoluteString + "/" + urlString + } + + return urlString + } + + + private func selectQualityBasedOnPreference(qualities: [QualityOption], preferredQuality: String) -> QualityOption { + guard qualities.count > 1 else { + logQualitySelectionSingle() + return qualities[0] + } + + let (autoQuality, sortedQualities) = categorizeQualities(qualities: qualities) + logQualitySelectionStart(preference: preferredQuality, sortedCount: sortedQualities.count) + + let selected = selectQualityByPreference( + preference: preferredQuality, + sortedQualities: sortedQualities, + autoQuality: autoQuality, + fallback: qualities[0] + ) + + logQualitySelectionResult(quality: selected, preference: preferredQuality) + return selected + } + + private func categorizeQualities(qualities: [QualityOption]) -> (auto: QualityOption?, sorted: [QualityOption]) { + let autoQuality = qualities.first { $0.name.contains("Auto") } + let nonAutoQualities = qualities.filter { !$0.name.contains("Auto") } + + let sortedQualities = nonAutoQualities.sorted { first, second in + let firstHeight = first.height ?? extractHeight(from: first.name) + let secondHeight = second.height ?? extractHeight(from: second.name) + return firstHeight > secondHeight + } + + return (autoQuality, sortedQualities) + } + + private func selectQualityByPreference(preference: String, sortedQualities: [QualityOption], + autoQuality: QualityOption?, fallback: QualityOption) -> QualityOption { + switch preference { + case "Best": + return sortedQualities.first ?? fallback + case "High": + return findQualityByType(["720p", "HD"], in: sortedQualities) ?? sortedQualities.first ?? fallback + case "Medium": + return findQualityByType(["480p", "SD"], in: sortedQualities) + ?? (sortedQualities.isEmpty ? fallback : sortedQualities[sortedQualities.count / 2]) + case "Low": + return sortedQualities.last ?? fallback + default: + return autoQuality ?? fallback + } + } + + private func findQualityByType(_ types: [String], in qualities: [QualityOption]) -> QualityOption? { + return qualities.first { quality in + types.contains { quality.name.contains($0) } + } + } + + private func extractHeight(from qualityName: String) -> Int { + return Int(qualityName.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0 + } + + + private func validateURL(_ url: URL) -> Bool { + return url.scheme == "http" || url.scheme == "https" + } + + private func createAssetMetadata(from request: DownloadRequest) -> AssetMetadata? { + guard let title = request.title else { return nil } + + return AssetMetadata( + title: title, + posterURL: request.imageURL, + showTitle: request.showTitle, + season: request.season, + episode: request.episode, + showPosterURL: request.showPosterURL ?? request.imageURL + ) + } + + private func createActiveDownload(id: UUID, request: DownloadRequest, asset: AVURLAsset, + downloadTask: AVAssetDownloadTask? = nil, urlSessionTask: URLSessionDownloadTask? = nil, + downloadType: DownloadType, metadata: AssetMetadata?) -> JSActiveDownload { + return JSActiveDownload( + id: id, + originalURL: request.url, + progress: 0.0, + task: downloadTask, + urlSessionTask: urlSessionTask, + queueStatus: .downloading, + type: downloadType, + metadata: metadata, + title: request.title, + imageURL: request.imageURL, + subtitleURL: request.subtitleURL, + asset: asset, + headers: request.headers, + module: nil + ) + } + + private func addActiveDownload(_ download: JSActiveDownload, task: URLSessionTask) { + activeDownloads.append(download) + activeDownloadMap[task] = download.id + } + + private func postDownloadNotification() { + DispatchQueue.main.async { + NotificationCenter.default.post(name: NSNotification.Name("downloadStatusChanged"), object: nil) + } + } + + private func downloadWithOriginalMethod(request: DownloadRequest, completionHandler: ((Bool, String) -> Void)?) { + self.startDownload( + url: request.url, + headers: request.headers, + title: request.title, + imageURL: request.imageURL, + isEpisode: request.isEpisode, + showTitle: request.showTitle, + season: request.season, + episode: request.episode, + subtitleURL: request.subtitleURL, + showPosterURL: request.showPosterURL, + completionHandler: completionHandler + ) + } + + + private func setupMP4ProgressObservation(for task: AVAssetDownloadTask, downloadID: UUID) { + let observation = task.progress.observe(\.fractionCompleted, options: [.new]) { [weak self] progress, _ in + DispatchQueue.main.async { + guard let self = self else { return } + self.updateMP4DownloadProgress(task: task, progress: progress.fractionCompleted) + NotificationCenter.default.post(name: NSNotification.Name("downloadProgressChanged"), object: nil) + } + } + + if mp4ProgressObservations == nil { + mp4ProgressObservations = [:] + } + mp4ProgressObservations?[downloadID] = observation + } + + private func updateMP4DownloadProgress(task: AVAssetDownloadTask, progress: Double) { + guard let downloadID = activeDownloadMap[task], + let downloadIndex = activeDownloads.firstIndex(where: { $0.id == downloadID }) else { + return + } + activeDownloads[downloadIndex].progress = progress + } + + func cleanupMP4ProgressObservation(for downloadID: UUID) { + mp4ProgressObservations?[downloadID]?.invalidate() + mp4ProgressObservations?[downloadID] = nil + } +} + + +extension JSController { + private func logDownloadStart(request: DownloadRequest) { + Logger.shared.log("Download process started for URL: \(request.url.absoluteString)", type: "Download") + Logger.shared.log("Title: \(request.title ?? "None"), Episode: \(request.isEpisode ? "Yes" : "No")", type: "Debug") + if let showTitle = request.showTitle, let episode = request.episode { + Logger.shared.log("Show: \(showTitle), Season: \(request.season ?? 1), Episode: \(episode)", type: "Debug") + } + if let subtitle = request.subtitleURL { + Logger.shared.log("Subtitle URL provided: \(subtitle.absoluteString)", type: "Debug") + } + } + + private func logM3U8Detection(preferredQuality: String) { + Logger.shared.log("M3U8 playlist detected - quality preference: \(preferredQuality)", type: "Download") + } + + private func logM3U8NoQualities() { + Logger.shared.log("No quality options found in M3U8, using original URL", type: "Warning") + } + + private func logM3U8QualitiesFound(qualities: [QualityOption]) { + Logger.shared.log("Found \(qualities.count) quality options in M3U8 playlist", type: "Download") + for (index, quality) in qualities.enumerated() { + Logger.shared.log("Quality \(index + 1): \(quality.name)", type: "Debug") + } + } + + private func logM3U8QualitySelected(quality: QualityOption) { + Logger.shared.log("Selected quality: \(quality.name)", type: "Download") + Logger.shared.log("Final download URL: \(quality.url)", type: "Debug") + } + + private func logM3U8InvalidURL() { + Logger.shared.log("Invalid quality URL detected, falling back to original", type: "Warning") + } + + private func logDirectDownload() { + Logger.shared.log("Direct download initiated (non-M3U8)", type: "Download") + } + + private func logMP4Detection() { + Logger.shared.log("MP4 stream detected, using MP4 download method", type: "Download") + } + + private func logM3U8FetchStart(url: URL) { + Logger.shared.log("Fetching M3U8 content from: \(url.absoluteString)", type: "Debug") + } + + private func logHTTPStatus(_ statusCode: Int, for url: URL) { + let logType = statusCode >= 400 ? "Error" : "Debug" + Logger.shared.log("HTTP \(statusCode) for M3U8 request: \(url.absoluteString)", type: logType) + } + + private func logM3U8FetchError(_ error: Error) { + Logger.shared.log("Failed to fetch M3U8 content: \(error.localizedDescription)", type: "Error") + } + + private func logM3U8DecodeError() { + Logger.shared.log("Failed to decode M3U8 file content", type: "Error") + } + + private func logM3U8FetchSuccess(dataSize: Int) { + Logger.shared.log("Successfully fetched M3U8 content (\(dataSize) bytes)", type: "Debug") + } + + private func logM3U8ParseStart(lineCount: Int) { + Logger.shared.log("Parsing M3U8 file with \(lineCount) lines", type: "Debug") + } + + private func logM3U8QualityAdded(quality: QualityOption) { + Logger.shared.log("Added quality option: \(quality.name)", type: "Debug") + } + + private func logM3U8ParseComplete(qualityCount: Int) { + Logger.shared.log("M3U8 parsing complete: \(qualityCount) quality options found", type: "Debug") + } + + private func logQualitySelectionSingle() { + Logger.shared.log("Only one quality available, using default", type: "Debug") + } + + private func logQualitySelectionStart(preference: String, sortedCount: Int) { + Logger.shared.log("Quality selection: \(sortedCount) options, preference: \(preference)", type: "Debug") + } + + private func logQualitySelectionResult(quality: QualityOption, preference: String) { + Logger.shared.log("Quality selected: \(quality.name) (preference: \(preference))", type: "Download") + } +} diff --git a/Sora/Utils/JSLoader/Downloads/JSController+M3U8Download.swift b/Sora/Utils/JSLoader/Downloads/JSController+M3U8Download.swift deleted file mode 100644 index 765253c..0000000 --- a/Sora/Utils/JSLoader/Downloads/JSController+M3U8Download.swift +++ /dev/null @@ -1,384 +0,0 @@ -// -// JSController+M3U8Download.swift -// Sora -// -// Created by doomsboygaming on 5/22/25 -// - -import Foundation -import SwiftUI - -// No need to import DownloadQualityPreference as it's in the same module - -// Extension for integrating M3U8StreamExtractor with JSController for downloads -extension JSController { - - /// Initiates a download for a given URL, handling M3U8 playlists if necessary - /// - Parameters: - /// - url: The URL to download - /// - headers: HTTP headers to use for the request - /// - title: Title for the download (optional) - /// - imageURL: Image URL for the content (optional) - /// - isEpisode: Whether this is an episode (defaults to false) - /// - showTitle: Title of the show this episode belongs to (optional) - /// - season: Season number (optional) - /// - episode: Episode number (optional) - /// - subtitleURL: Optional subtitle URL to download after video (optional) - /// - completionHandler: Called when the download is initiated or fails - func downloadWithM3U8Support(url: URL, headers: [String: String], title: String? = nil, - imageURL: URL? = nil, isEpisode: Bool = false, - showTitle: String? = nil, season: Int? = nil, episode: Int? = nil, - subtitleURL: URL? = nil, showPosterURL: URL? = nil, - completionHandler: ((Bool, String) -> Void)? = nil) { - // Use headers passed in from caller rather than generating our own baseUrl - // Receiving code should already be setting module.metadata.baseUrl - - print("---- DOWNLOAD PROCESS STARTED ----") - print("Original URL: \(url.absoluteString)") - print("Headers: \(headers)") - print("Title: \(title ?? "None")") - print("Is Episode: \(isEpisode), Show: \(showTitle ?? "None"), Season: \(season?.description ?? "None"), Episode: \(episode?.description ?? "None")") - if let subtitle = subtitleURL { - print("Subtitle URL: \(subtitle.absoluteString)") - } - - // Check if the URL is an M3U8 file - if url.absoluteString.contains(".m3u8") { - // Get the user's quality preference - let preferredQuality = DownloadQualityPreference.current.rawValue - - print("URL detected as M3U8 playlist - will select quality based on user preference: \(preferredQuality)") - - // Parse the M3U8 content to extract available qualities, matching CustomPlayer approach - parseM3U8(url: url, baseUrl: url.absoluteString, headers: headers) { [weak self] qualities in - DispatchQueue.main.async { - guard let self = self else { return } - - if qualities.isEmpty { - print("M3U8 Analysis: No quality options found in M3U8, downloading with original URL") - self.downloadWithOriginalMethod( - url: url, - headers: headers, - title: title, - imageURL: imageURL, - isEpisode: isEpisode, - showTitle: showTitle, - season: season, - episode: episode, - subtitleURL: subtitleURL, - showPosterURL: showPosterURL, - completionHandler: completionHandler - ) - return - } - - print("M3U8 Analysis: Found \(qualities.count) quality options") - for (index, quality) in qualities.enumerated() { - print(" \(index + 1). \(quality.0) - \(quality.1)") - } - - // Select appropriate quality based on user preference - let selectedQuality = self.selectQualityBasedOnPreference(qualities: qualities, preferredQuality: preferredQuality) - - print("M3U8 Analysis: Selected quality: \(selectedQuality.0)") - print("M3U8 Analysis: Selected URL: \(selectedQuality.1)") - - if let qualityURL = URL(string: selectedQuality.1) { - print("FINAL DOWNLOAD URL: \(qualityURL.absoluteString)") - print("QUALITY SELECTED: \(selectedQuality.0)") - - // Download with standard headers that match the player - self.downloadWithOriginalMethod( - url: qualityURL, - headers: headers, - title: title, - imageURL: imageURL, - isEpisode: isEpisode, - showTitle: showTitle, - season: season, - episode: episode, - subtitleURL: subtitleURL, - showPosterURL: showPosterURL, - completionHandler: completionHandler - ) - } else { - print("M3U8 Analysis: Invalid quality URL, falling back to original URL") - print("FINAL DOWNLOAD URL (fallback): \(url.absoluteString)") - - self.downloadWithOriginalMethod( - url: url, - headers: headers, - title: title, - imageURL: imageURL, - isEpisode: isEpisode, - showTitle: showTitle, - season: season, - episode: episode, - subtitleURL: subtitleURL, - showPosterURL: showPosterURL, - completionHandler: completionHandler - ) - } - } - } - } else { - // Not an M3U8 file, use the original download method with standard headers - print("URL is not an M3U8 playlist - downloading directly") - print("FINAL DOWNLOAD URL (direct): \(url.absoluteString)") - - downloadWithOriginalMethod( - url: url, - headers: headers, - title: title, - imageURL: imageURL, - isEpisode: isEpisode, - showTitle: showTitle, - season: season, - episode: episode, - subtitleURL: subtitleURL, - showPosterURL: showPosterURL, - completionHandler: completionHandler - ) - } - } - - /// Parses an M3U8 file to extract available quality options, matching CustomPlayer's approach exactly - /// - Parameters: - /// - url: The URL of the M3U8 file - /// - baseUrl: The base URL for setting headers - /// - headers: HTTP headers to use for the request - /// - completion: Called with the array of quality options (name, URL) - private func parseM3U8(url: URL, baseUrl: String, headers: [String: String], completion: @escaping ([(String, String)]) -> Void) { - var request = URLRequest(url: url) - - // Add headers from headers passed to downloadWithM3U8Support - // This ensures we use the same headers as the player (from module.metadata.baseUrl) - for (key, value) in headers { - request.addValue(value, forHTTPHeaderField: key) - } - - print("M3U8 Parser: Fetching M3U8 content from: \(url.absoluteString)") - - URLSession.shared.dataTask(with: request) { data, response, error in - // Log HTTP status for debugging - if let httpResponse = response as? HTTPURLResponse { - print("M3U8 Parser: HTTP Status: \(httpResponse.statusCode) for \(url.absoluteString)") - - if httpResponse.statusCode >= 400 { - print("M3U8 Parser: HTTP Error: \(httpResponse.statusCode)") - completion([]) - return - } - } - - if let error = error { - print("M3U8 Parser: Error fetching M3U8: \(error.localizedDescription)") - completion([]) - return - } - - guard let data = data, let content = String(data: data, encoding: .utf8) else { - print("M3U8 Parser: Failed to load or decode M3U8 file") - completion([]) - return - } - - print("M3U8 Parser: Successfully fetched M3U8 content (\(data.count) bytes)") - - let lines = content.components(separatedBy: .newlines) - print("M3U8 Parser: Found \(lines.count) lines in M3U8 file") - - var qualities: [(String, String)] = [] - - // Always include the original URL as "Auto" option - qualities.append(("Auto (Recommended)", url.absoluteString)) - print("M3U8 Parser: Added 'Auto' quality option with original URL") - - func getQualityName(for height: Int) -> String { - switch height { - case 1080...: return "\(height)p (FHD)" - case 720..<1080: return "\(height)p (HD)" - case 480..<720: return "\(height)p (SD)" - default: return "\(height)p" - } - } - - // Parse the M3U8 content to extract available streams - exactly like CustomPlayer - print("M3U8 Parser: Scanning for quality options...") - var qualitiesFound = 0 - - for (index, line) in lines.enumerated() { - if line.contains("#EXT-X-STREAM-INF"), index + 1 < lines.count { - print("M3U8 Parser: Found stream info at line \(index): \(line)") - - if let resolutionRange = line.range(of: "RESOLUTION="), - let resolutionEndRange = line[resolutionRange.upperBound...].range(of: ",") - ?? line[resolutionRange.upperBound...].range(of: "\n") { - - let resolutionPart = String(line[resolutionRange.upperBound.. (String, String) { - // If only one quality is available, return it - if qualities.count <= 1 { - print("Quality Selection: Only one quality option available, returning it directly") - return qualities[0] - } - - // Extract "Auto" quality and the remaining qualities - let autoQuality = qualities.first { $0.0.contains("Auto") } - let nonAutoQualities = qualities.filter { !$0.0.contains("Auto") } - - print("Quality Selection: Found \(nonAutoQualities.count) non-Auto quality options") - print("Quality Selection: Auto quality option: \(autoQuality?.0 ?? "None")") - - // Sort non-auto qualities by resolution (highest first) - let sortedQualities = nonAutoQualities.sorted { first, second in - let firstHeight = Int(first.0.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0 - let secondHeight = Int(second.0.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()) ?? 0 - return firstHeight > secondHeight - } - - print("Quality Selection: Sorted qualities (highest to lowest):") - for (index, quality) in sortedQualities.enumerated() { - print(" \(index + 1). \(quality.0) - \(quality.1)") - } - - print("Quality Selection: User preference is '\(preferredQuality)'") - - // Select quality based on preference - switch preferredQuality { - case "Best": - // Return the highest quality (first in sorted list) - let selected = sortedQualities.first ?? qualities[0] - print("Quality Selection: Selected 'Best' quality: \(selected.0)") - return selected - - case "High": - // Look for 720p quality - let highQuality = sortedQualities.first { - $0.0.contains("720p") || $0.0.contains("HD") - } - - if let high = highQuality { - print("Quality Selection: Found specific 'High' (720p/HD) quality: \(high.0)") - return high - } else if let first = sortedQualities.first { - print("Quality Selection: No specific 'High' quality found, using highest available: \(first.0)") - return first - } else { - print("Quality Selection: No non-Auto qualities found, falling back to default: \(qualities[0].0)") - return qualities[0] - } - - case "Medium": - // Look for 480p quality - let mediumQuality = sortedQualities.first { - $0.0.contains("480p") || $0.0.contains("SD") - } - - if let medium = mediumQuality { - print("Quality Selection: Found specific 'Medium' (480p/SD) quality: \(medium.0)") - return medium - } else if !sortedQualities.isEmpty { - // Return middle quality from sorted list if no exact match - let middleIndex = sortedQualities.count / 2 - print("Quality Selection: No specific 'Medium' quality found, using middle quality: \(sortedQualities[middleIndex].0)") - return sortedQualities[middleIndex] - } else { - print("Quality Selection: No non-Auto qualities found, falling back to default: \(autoQuality?.0 ?? qualities[0].0)") - return autoQuality ?? qualities[0] - } - - case "Low": - // Return lowest quality (last in sorted list) - if let lowest = sortedQualities.last { - print("Quality Selection: Selected 'Low' quality: \(lowest.0)") - return lowest - } else { - print("Quality Selection: No non-Auto qualities found, falling back to default: \(autoQuality?.0 ?? qualities[0].0)") - return autoQuality ?? qualities[0] - } - - default: - // Default to Auto if available, otherwise first quality - if let auto = autoQuality { - print("Quality Selection: Default case, using Auto quality: \(auto.0)") - return auto - } else { - print("Quality Selection: No Auto quality found, using first available: \(qualities[0].0)") - return qualities[0] - } - } - } - - /// The original download method (adapted to be called internally) - /// This method should match the existing download implementation in JSController-Downloads.swift - private func downloadWithOriginalMethod(url: URL, headers: [String: String], title: String? = nil, - imageURL: URL? = nil, isEpisode: Bool = false, - showTitle: String? = nil, season: Int? = nil, episode: Int? = nil, - subtitleURL: URL? = nil, showPosterURL: URL? = nil, - completionHandler: ((Bool, String) -> Void)? = nil) { - // Call the existing download method - self.startDownload( - url: url, - headers: headers, - title: title, - imageURL: imageURL, - isEpisode: isEpisode, - showTitle: showTitle, - season: season, - episode: episode, - subtitleURL: subtitleURL, - showPosterURL: showPosterURL, - completionHandler: completionHandler - ) - } -} diff --git a/Sora/Utils/JSLoader/Downloads/JSController+MP4Download.swift b/Sora/Utils/JSLoader/Downloads/JSController+MP4Download.swift deleted file mode 100644 index 27e12e6..0000000 --- a/Sora/Utils/JSLoader/Downloads/JSController+MP4Download.swift +++ /dev/null @@ -1,158 +0,0 @@ -// -// JSController+MP4Download.swift -// Sora -// -// Created by doomsboygaming on 5/22/25 -// - -import Foundation -import SwiftUI -import AVFoundation - -// Extension for handling MP4 direct video downloads using AVAssetDownloadTask -extension JSController { - - /// Initiates a download for a given MP4 URL using the existing AVAssetDownloadURLSession - /// - Parameters: - /// - url: The MP4 URL to download - /// - headers: HTTP headers to use for the request - /// - title: Title for the download (optional) - /// - imageURL: Image URL for the content (optional) - /// - isEpisode: Whether this is an episode (defaults to false) - /// - showTitle: Title of the show this episode belongs to (optional) - /// - season: Season number (optional) - /// - episode: Episode number (optional) - /// - subtitleURL: Optional subtitle URL to download after video (optional) - /// - completionHandler: Called when the download is initiated or fails - func downloadMP4(url: URL, headers: [String: String], title: String? = nil, - imageURL: URL? = nil, isEpisode: Bool = false, - showTitle: String? = nil, season: Int? = nil, episode: Int? = nil, - subtitleURL: URL? = nil, showPosterURL: URL? = nil, - completionHandler: ((Bool, String) -> Void)? = nil) { - - // Validate URL - guard url.scheme == "http" || url.scheme == "https" else { - completionHandler?(false, "Invalid URL scheme") - return - } - - // Ensure download session is available - guard let downloadSession = downloadURLSession else { - completionHandler?(false, "Download session not available") - return - } - - // Create metadata for the download - var metadata: AssetMetadata? = nil - if let title = title { - metadata = AssetMetadata( - title: title, - posterURL: imageURL, - showTitle: showTitle, - season: season, - episode: episode, - showPosterURL: showPosterURL ?? imageURL - ) - } - - // Determine download type based on isEpisode - let downloadType: DownloadType = isEpisode ? .episode : .movie - - // Generate a unique download ID - let downloadID = UUID() - - // Create AVURLAsset with headers passed through AVURLAssetHTTPHeaderFieldsKey - let asset = AVURLAsset(url: url, options: [ - "AVURLAssetHTTPHeaderFieldsKey": headers - ]) - - // Create AVAssetDownloadTask using existing session - guard let downloadTask = downloadSession.makeAssetDownloadTask( - asset: asset, - assetTitle: title ?? url.lastPathComponent, - assetArtworkData: nil, - options: [AVAssetDownloadTaskMinimumRequiredMediaBitrateKey: 2_000_000] - ) else { - completionHandler?(false, "Failed to create download task") - return - } - - // Create an active download object - let activeDownload = JSActiveDownload( - id: downloadID, - originalURL: url, - progress: 0.0, - task: downloadTask, - urlSessionTask: nil, - queueStatus: .downloading, - type: downloadType, - metadata: metadata, - title: title, - imageURL: imageURL, - subtitleURL: subtitleURL, - asset: asset, - headers: headers, - module: nil - ) - - // Add to active downloads and tracking - activeDownloads.append(activeDownload) - activeDownloadMap[downloadTask] = downloadID - - // Set up progress observation for MP4 downloads - setupMP4ProgressObservation(for: downloadTask, downloadID: downloadID) - - // Start the download - downloadTask.resume() - - // Post notification for UI updates using NotificationCenter directly since postDownloadNotification is private - DispatchQueue.main.async { - NotificationCenter.default.post(name: NSNotification.Name("downloadStatusChanged"), object: nil) - } - - // Initial success callback - completionHandler?(true, "Download started") - } - - // MARK: - MP4 Progress Observation - - /// Sets up progress observation for MP4 downloads using AVAssetDownloadTask - /// Since AVAssetDownloadTask doesn't provide progress for single MP4 files through delegate methods, - /// we observe the task's progress property directly - private func setupMP4ProgressObservation(for task: AVAssetDownloadTask, downloadID: UUID) { - let observation = task.progress.observe(\.fractionCompleted, options: [.new]) { [weak self] progress, _ in - DispatchQueue.main.async { - guard let self = self else { return } - - // Update download progress using existing infrastructure - self.updateMP4DownloadProgress(task: task, progress: progress.fractionCompleted) - - // Post notification for UI updates - NotificationCenter.default.post(name: NSNotification.Name("downloadProgressChanged"), object: nil) - } - } - - // Store observation for cleanup using existing property from main JSController class - if mp4ProgressObservations == nil { - mp4ProgressObservations = [:] - } - mp4ProgressObservations?[downloadID] = observation - } - - /// Updates download progress for a specific MP4 task (avoiding name collision with existing method) - private func updateMP4DownloadProgress(task: AVAssetDownloadTask, progress: Double) { - guard let downloadID = activeDownloadMap[task], - let downloadIndex = activeDownloads.firstIndex(where: { $0.id == downloadID }) else { - return - } - - // Update progress using existing mechanism - activeDownloads[downloadIndex].progress = progress - } - - /// Cleans up MP4 progress observation for a specific download - func cleanupMP4ProgressObservation(for downloadID: UUID) { - mp4ProgressObservations?[downloadID]?.invalidate() - mp4ProgressObservations?[downloadID] = nil - } -} diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 17f705a..dea242c 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -93,12 +93,11 @@ 7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */; }; 722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */; }; 722248632DCBAA4700CABE2D /* JSController-HeaderManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */; }; - 722248642DCBAA4700CABE2D /* JSController+M3U8Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */; }; 722248662DCBC13E00CABE2D /* JSController-Downloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 722248652DCBC13E00CABE2D /* JSController-Downloads.swift */; }; 72443C7D2DC8036500A61321 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72443C7C2DC8036500A61321 /* DownloadView.swift */; }; 72443C7F2DC8038300A61321 /* SettingsViewDownloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */; }; 727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */; }; - 727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */ = {isa = PBXBuildFile; fileRef = 727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */; }; + 72D546DB2DFC5E950044C567 /* JSController+Downloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72D546DA2DFC5E950044C567 /* JSController+Downloader.swift */; }; 73D164D52D8B5B470011A360 /* JavaScriptCore+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */; }; /* End PBXBuildFile section */ @@ -187,12 +186,11 @@ 7222485D2DCBAA2C00CABE2D /* DownloadModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadModels.swift; sourceTree = ""; }; 7222485E2DCBAA2C00CABE2D /* M3U8StreamExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = M3U8StreamExtractor.swift; sourceTree = ""; }; 722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-HeaderManager.swift"; sourceTree = ""; }; - 722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+M3U8Download.swift"; sourceTree = ""; }; 722248652DCBC13E00CABE2D /* JSController-Downloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Downloads.swift"; sourceTree = ""; }; 72443C7C2DC8036500A61321 /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = ""; }; 72443C7E2DC8038300A61321 /* SettingsViewDownloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewDownloads.swift; sourceTree = ""; }; 7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-StreamTypeDownload.swift"; sourceTree = ""; }; - 727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+MP4Download.swift"; sourceTree = ""; }; + 72D546DA2DFC5E950044C567 /* JSController+Downloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController+Downloader.swift"; sourceTree = ""; }; 73D164D42D8B5B340011A360 /* JavaScriptCore+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JavaScriptCore+Extensions.swift"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -470,11 +468,10 @@ 134A387B2DE4B5B90041B687 /* Downloads */ = { isa = PBXGroup; children = ( + 72D546DA2DFC5E950044C567 /* JSController+Downloader.swift */, 7272206F2DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift */, - 727220702DD642B100C2A4A2 /* JSController+MP4Download.swift */, 722248652DCBC13E00CABE2D /* JSController-Downloads.swift */, 722248612DCBAA4700CABE2D /* JSController-HeaderManager.swift */, - 722248622DCBAA4700CABE2D /* JSController+M3U8Download.swift */, ); path = Downloads; sourceTree = ""; @@ -757,7 +754,6 @@ 13DB468E2D90093A008CBC03 /* Anilist-Token.swift in Sources */, 1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */, 722248632DCBAA4700CABE2D /* JSController-HeaderManager.swift in Sources */, - 722248642DCBAA4700CABE2D /* JSController+M3U8Download.swift in Sources */, 133D7C942D2BE2640075467E /* JSController.swift in Sources */, 133D7C922D2BE2640075467E /* URLSession.swift in Sources */, 0457C5A12DE78385000AFBD9 /* BookmarksDetailView.swift in Sources */, @@ -776,9 +772,9 @@ 13B77E202DA457AA00126FDF /* AniListPushUpdates.swift in Sources */, 13EA2BD52D32D97400C1EBD7 /* CustomPlayer.swift in Sources */, 727220712DD642B100C2A4A2 /* JSController-StreamTypeDownload.swift in Sources */, - 727220722DD642B100C2A4A2 /* JSController+MP4Download.swift in Sources */, 0402DA132DE7B5EC003BB42C /* SearchStateView.swift in Sources */, 0402DA142DE7B5EC003BB42C /* SearchResultsGrid.swift in Sources */, + 72D546DB2DFC5E950044C567 /* JSController+Downloader.swift in Sources */, 0402DA152DE7B5EC003BB42C /* SearchComponents.swift in Sources */, 1399FAD42D3AB38C00E97C31 /* SettingsViewLogger.swift in Sources */, 04F08EDF2DE10C1D006B29D9 /* TabBar.swift in Sources */, From 2026e43630e5981985a224612646c94373932409 Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Sat, 14 Jun 2025 09:16:00 +0200 Subject: [PATCH 46/52] anilist logic improved basically (#189) * removed double bs for id telling * improved anilist logic, single episode anilist sync, anilist sync also avaiaiable with tmdb as provider, tmdb posters avaiabale with anilist as provider --- .../Mutations/AniListPushUpdates.swift | 44 +++++ .../AnilistMatchPopupView.swift | 0 .../TMDBMatchPopupView.swift | 0 .../EpisodeCell/EpisodeCell.swift | 29 +++- Sora/Views/MediaInfoView/MediaInfoView.swift | 154 ++++++++++++------ Sulfur.xcodeproj/project.pbxproj | 12 +- 6 files changed, 183 insertions(+), 56 deletions(-) rename Sora/Views/MediaInfoView/{ => CustomMatching}/AnilistMatchPopupView.swift (100%) rename Sora/Views/MediaInfoView/{ => CustomMatching}/TMDBMatchPopupView.swift (100%) diff --git a/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift b/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift index 27aa960..7569809 100644 --- a/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift +++ b/Sora/Tracking Services/AniList/Mutations/AniListPushUpdates.swift @@ -195,6 +195,50 @@ class AniListMutation { }.resume() } + func fetchCoverImage( + animeId: Int, + completion: @escaping (Result) -> Void + ) { + let query = """ + query ($id: Int) { + Media(id: $id, type: ANIME) { + coverImage { large } + } + } + """ + let variables = ["id": animeId] + let body: [String: Any] = ["query": query, "variables": variables] + + guard let url = URL(string: "https://graphql.anilist.co"), + let httpBody = try? JSONSerialization.data(withJSONObject: body) + else { + completion(.failure(NSError(domain: "AniList", code: 0, userInfo: [NSLocalizedDescriptionKey: "Invalid URL or payload"]))) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = httpBody + + URLSession.shared.dataTask(with: request) { data, _, error in + if let error = error { + return completion(.failure(error)) + } + guard let data = data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let dataDict = json["data"] as? [String: Any], + let media = dataDict["Media"] as? [String: Any], + let cover = media["coverImage"] as? [String: Any], + let imageUrl = cover["large"] as? String + else { + return completion(.failure(NSError(domain: "AniList", code: 1, userInfo: [NSLocalizedDescriptionKey: "Malformed response"]))) + } + completion(.success(imageUrl)) + } + .resume() + } + private struct AniListMediaResponse: Decodable { struct DataField: Decodable { struct Media: Decodable { let idMal: Int? } diff --git a/Sora/Views/MediaInfoView/AnilistMatchPopupView.swift b/Sora/Views/MediaInfoView/CustomMatching/AnilistMatchPopupView.swift similarity index 100% rename from Sora/Views/MediaInfoView/AnilistMatchPopupView.swift rename to Sora/Views/MediaInfoView/CustomMatching/AnilistMatchPopupView.swift diff --git a/Sora/Views/MediaInfoView/TMDBMatchPopupView.swift b/Sora/Views/MediaInfoView/CustomMatching/TMDBMatchPopupView.swift similarity index 100% rename from Sora/Views/MediaInfoView/TMDBMatchPopupView.swift rename to Sora/Views/MediaInfoView/CustomMatching/TMDBMatchPopupView.swift diff --git a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift index bad3e72..f8024d1 100644 --- a/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift +++ b/Sora/Views/MediaInfoView/EpisodeCell/EpisodeCell.swift @@ -111,6 +111,11 @@ struct EpisodeCell: View { .onDisappear { activeDownloadTask = nil } .onChange(of: progress) { _ in updateProgress() } .onChange(of: itemID) { _ in handleItemIDChange() } + .onChange(of: tmdbID) { _ in + isLoading = true + retryAttempts = 0 + fetchEpisodeDetails() + } .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("downloadProgressChanged"))) { _ in DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { updateDownloadStatus() @@ -437,12 +442,30 @@ private extension EpisodeCell { } func markAsWatched() { - let userDefaults = UserDefaults.standard + let defaults = UserDefaults.standard let totalTime = 1000.0 - userDefaults.set(totalTime, forKey: "lastPlayedTime_\(episode)") - userDefaults.set(totalTime, forKey: "totalTime_\(episode)") + defaults.set(totalTime, forKey: "lastPlayedTime_\(episode)") + defaults.set(totalTime, forKey: "totalTime_\(episode)") updateProgress() + + if itemID > 0 { + let epNum = episodeID + 1 + let newStatus = (epNum == totalEpisodes) ? "COMPLETED" : "CURRENT" + AniListMutation().updateAnimeProgress( + animeId: itemID, + episodeNumber: epNum, + status: newStatus + ) { result in + switch result { + case .success: + Logger.shared.log("AniList sync: marked ep \(epNum) as \(newStatus)", type: "General") + case .failure(let err): + Logger.shared.log("AniList sync failed: \(err.localizedDescription)", type: "Error") + } + } + } } + func resetProgress() { let userDefaults = UserDefaults.standard diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index e0d57d9..a4f5cb6 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -759,6 +759,8 @@ struct MediaInfoView: View { if savedCustomID != 0 { itemID = savedCustomID + activeProvider = "AniList" + UserDefaults.standard.set("AniList", forKey: "metadataProviders") } else { fetchMetadataIDIfNeeded() } @@ -798,29 +800,36 @@ struct MediaInfoView: View { } private func toggleSingleEpisodeWatchStatus() { - if let ep = episodeLinks.first { - let lastPlayedTime = UserDefaults.standard.double(forKey: "lastPlayedTime_\(ep.href)") - let totalTime = UserDefaults.standard.double(forKey: "totalTime_\(ep.href)") - let progress = totalTime > 0 ? lastPlayedTime / totalTime : 0 - - if progress <= 0.9 { - UserDefaults.standard.set(99999999.0, forKey: "lastPlayedTime_\(ep.href)") - UserDefaults.standard.set(99999999.0, forKey: "totalTime_\(ep.href)") - DropManager.shared.showDrop( - title: "Marked as Watched", - subtitle: "", - duration: 1.0, - icon: UIImage(systemName: "checkmark.circle.fill") - ) - } else { - UserDefaults.standard.set(0.0, forKey: "lastPlayedTime_\(ep.href)") - UserDefaults.standard.set(0.0, forKey: "totalTime_\(ep.href)") - DropManager.shared.showDrop( - title: "Progress Reset", - subtitle: "", - duration: 1.0, - icon: UIImage(systemName: "arrow.counterclockwise") - ) + guard let ep = episodeLinks.first else { return } + let lastPlayedKey = "lastPlayedTime_\(ep.href)" + let totalTimeKey = "totalTime_\(ep.href)" + let last = UserDefaults.standard.double(forKey: lastPlayedKey) + let total = UserDefaults.standard.double(forKey: totalTimeKey) + let progress = total > 0 ? last/total : 0 + let watchedEp = ep.number + + if progress <= 0.9 { + UserDefaults.standard.set(99999999.0, forKey: lastPlayedKey) + UserDefaults.standard.set(99999999.0, forKey: totalTimeKey) + DropManager.shared.showDrop(title: "Marked as Watched", subtitle: "", duration: 1.0, icon: UIImage(systemName: "checkmark.circle.fill")) + + if let listID = itemID, listID > 0 { + AniListMutation().updateAnimeProgress(animeId: listID, episodeNumber: watchedEp, status: "CURRENT") { result in + switch result { + case .success: + Logger.shared.log("AniList sync: marked ep \(watchedEp) as CURRENT", type: "General") + case .failure(let err): + Logger.shared.log("AniList sync failed: \(err.localizedDescription)", type: "Error") + } + } + } + } else { + UserDefaults.standard.set(0.0, forKey: lastPlayedKey) + UserDefaults.standard.set(0.0, forKey: totalTimeKey) + DropManager.shared.showDrop(title: "Progress Reset", subtitle: "", duration: 1.0, icon: UIImage(systemName: "arrow.counterclockwise")) + + if let listID = itemID, listID > 0 { + AniListMutation().updateAnimeProgress(animeId: listID, episodeNumber: 0, status: "CURRENT") { _ in } } } } @@ -883,6 +892,8 @@ struct MediaInfoView: View { private func handleAniListMatch(selectedID: Int) { self.customAniListID = selectedID self.itemID = selectedID + self.activeProvider = "AniList" + UserDefaults.standard.set("AniList", forKey: "metadataProviders") UserDefaults.standard.set(selectedID, forKey: "custom_anilist_id_\(href)") self.fetchDetails() isMatchingPresented = false @@ -1181,15 +1192,48 @@ struct MediaInfoView: View { } } - private func fetchMetadataIDIfNeeded() { + private func fetchAniListPosterImageAndSet() { + guard let listID = itemID, listID > 0 else { return } + AniListMutation().fetchCoverImage(animeId: listID) { result in + switch result { + case .success(let urlString): + DispatchQueue.main.async { + let originalKey = "originalPoster_\(self.href)" + UserDefaults.standard.set(self.imageUrl, forKey: originalKey) + self.imageUrl = urlString + } + case .failure(let err): + Logger.shared.log("AniList poster fetch failed: \(err.localizedDescription)", type: "Error") + } + } + } + + + private func fetchAniListIDForSync() { + let cleaned = cleanTitle(title) + fetchItemID(byTitle: cleaned) { result in + switch result { + case .success(let id): + DispatchQueue.main.async { + if customAniListID == nil { + self.itemID = id + } + } + case .failure(let err): + Logger.shared.log("AniList sync‐ID fetch failed: \(err.localizedDescription)", type: "Error") + } + } + } + + func fetchMetadataIDIfNeeded() { let order = metadataProvidersOrder let cleanedTitle = cleanTitle(title) - + itemID = nil tmdbID = nil activeProvider = nil isError = false - + func fetchAniList(completion: @escaping (Bool) -> Void) { fetchItemID(byTitle: cleanedTitle) { result in switch result { @@ -1198,55 +1242,63 @@ struct MediaInfoView: View { self.itemID = id self.activeProvider = "AniList" UserDefaults.standard.set("AniList", forKey: "metadataProviders") - completion(true) + + tmdbFetcher.fetchBestMatchID(for: cleanedTitle) { tmdbId, tmdbType in + DispatchQueue.main.async { + guard let tmdbId = tmdbId, let tmdbType = tmdbType else { + completion(true) + return + } + self.tmdbID = tmdbId + self.tmdbType = tmdbType + self.fetchTMDBPosterImageAndSet() + completion(true) + } + } } + case .failure(let error): Logger.shared.log("Failed to fetch AniList ID for tracking: \(error)", type: "Error") completion(false) } } } - - func fetchTMDB(completion: @escaping (Bool) -> Void) { - tmdbFetcher.fetchBestMatchID(for: cleanedTitle) { id, type in - DispatchQueue.main.async { - if let id = id, let type = type { - self.tmdbID = id - self.tmdbType = type - self.activeProvider = "TMDB" - UserDefaults.standard.set("TMDB", forKey: "metadataProviders") - completion(true) - } else { - completion(false) - } - } - } - } - + func tryProviders(_ index: Int) { guard index < order.count else { isError = true return } + let provider = order[index] - if provider == "AniList" { + switch provider { + case "AniList": fetchAniList { success in if !success { tryProviders(index + 1) } } - } else if provider == "TMDB" { - fetchTMDB { success in - if !success { - tryProviders(index + 1) + case "TMDB": + tmdbFetcher.fetchBestMatchID(for: cleanedTitle) { id, type in + DispatchQueue.main.async { + if let id = id, let type = type { + self.tmdbID = id + self.tmdbType = type + self.activeProvider = "TMDB" + UserDefaults.standard.set("TMDB", forKey: "metadataProviders") + self.fetchTMDBPosterImageAndSet() + } else { + tryProviders(index + 1) + } } } - } else { + default: tryProviders(index + 1) } } - + tryProviders(0) + fetchAniListIDForSync() } private func fetchItemID(byTitle title: String, completion: @escaping (Result) -> Void) { diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index dea242c..ba5a9f6 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -368,10 +368,9 @@ 133D7C7F2D2BE2630075467E /* MediaInfoView */ = { isa = PBXGroup; children = ( + 1E0435F02DFCB86800FF6808 /* CustomMatching */, 138AA1B52D2D66EC0021F9DF /* EpisodeCell */, - 1EDA48F32DFAC374002A4EC3 /* TMDBMatchPopupView.swift */, 133D7C802D2BE2630075467E /* MediaInfoView.swift */, - 1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */, ); path = MediaInfoView; sourceTree = ""; @@ -609,6 +608,15 @@ path = Components; sourceTree = ""; }; + 1E0435F02DFCB86800FF6808 /* CustomMatching */ = { + isa = PBXGroup; + children = ( + 1EDA48F32DFAC374002A4EC3 /* TMDBMatchPopupView.swift */, + 1E47859A2DEBC5960095BF2F /* AnilistMatchPopupView.swift */, + ); + path = CustomMatching; + sourceTree = ""; + }; 72443C832DC8046500A61321 /* DownloadUtils */ = { isa = PBXGroup; children = ( From 63b1b20f5af6fa81c420ffb3ea6fc721f08c027b Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Sat, 14 Jun 2025 10:18:43 +0200 Subject: [PATCH 47/52] maybe DNS --- Sora/Utils/Extensions/URLSession.swift | 3 +- .../NetworkConfig/DNSConfiguration.swift | 47 +++++++ .../SettingsSubViews/SettingsViewDNS.swift | 116 ++++++++++++++++++ Sora/Views/SettingsView/SettingsView.swift | 5 + Sulfur.xcodeproj/project.pbxproj | 16 +++ 5 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 Sora/Utils/NetworkConfig/DNSConfiguration.swift create mode 100644 Sora/Views/SettingsView/SettingsSubViews/SettingsViewDNS.swift diff --git a/Sora/Utils/Extensions/URLSession.swift b/Sora/Utils/Extensions/URLSession.swift index d1cacb7..7fae451 100644 --- a/Sora/Utils/Extensions/URLSession.swift +++ b/Sora/Utils/Extensions/URLSession.swift @@ -60,7 +60,8 @@ extension URLSession { static let custom: URLSession = { let configuration = URLSessionConfiguration.default configuration.httpAdditionalHeaders = ["User-Agent": randomUserAgent] - return URLSession(configuration: configuration) + let session = URLSession(configuration: configuration) + return DNSConfiguration.shared.configureDNS(for: session) }() static func fetchData(allowRedirects:Bool) -> URLSession diff --git a/Sora/Utils/NetworkConfig/DNSConfiguration.swift b/Sora/Utils/NetworkConfig/DNSConfiguration.swift new file mode 100644 index 0000000..f38dd38 --- /dev/null +++ b/Sora/Utils/NetworkConfig/DNSConfiguration.swift @@ -0,0 +1,47 @@ +// +// DNSConfiguration.swift +// Sulfur +// +// Created by Francesco on 14/06/25. +// + +import Network +import Foundation + +enum DNSServer: String, CaseIterable { + case cloudflare = "1.1.1.1" + case cloudflareSecondary = "1.0.0.1" + case adGuard = "94.140.14.14" + case adGuardSecondary = "94.140.15.15" + case google = "8.8.8.8" + case googleSecondary = "8.8.4.4" + + static var current: [DNSServer] = [.cloudflare, .cloudflareSecondary] +} + +class DNSConfiguration { + static let shared = DNSConfiguration() + + private init() {} + + func configureDNS(for session: URLSession) -> URLSession { + let configuration = (session.configuration.copy() as! URLSessionConfiguration) + + let proxyDict: [AnyHashable: Any] = [ + kCFProxyTypeKey: kCFProxyTypeHTTPS, + kCFProxyHostNameKey: DNSServer.current[0].rawValue, + kCFProxyPortNumberKey: 443, + kCFProxyUsernameKey: "", + kCFProxyPasswordKey: "" + ] + + configuration.connectionProxyDictionary = proxyDict + + return URLSession(configuration: configuration, delegate: session.delegate, delegateQueue: session.delegateQueue) + } + + func setDNSServer(_ servers: [DNSServer]) { + DNSServer.current = servers + URLSession.shared.invalidateAndCancel() + } +} diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewDNS.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewDNS.swift new file mode 100644 index 0000000..8d3e545 --- /dev/null +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewDNS.swift @@ -0,0 +1,116 @@ +// +// SettingsViewDNS.swift +// Sulfur +// +// Created by Francesco on 14/06/25. +// + +import SwiftUI + +fileprivate struct SettingsSection: View { + let title: String + let footer: String? + let content: Content + + init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) { + self.title = title + self.footer = footer + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title.uppercased()) + .font(.footnote) + .foregroundStyle(.gray) + .padding(.horizontal, 20) + + VStack(spacing: 0) { + content + } + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .strokeBorder( + LinearGradient( + gradient: Gradient(stops: [ + .init(color: Color.accentColor.opacity(0.3), location: 0), + .init(color: Color.accentColor.opacity(0), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 0.5 + ) + ) + .padding(.horizontal, 20) + + if let footer = footer { + Text(footer) + .font(.footnote) + .foregroundStyle(.gray) + .padding(.horizontal, 20) + .padding(.top, 4) + } + } + } +} + +struct SettingsViewDNS: View { + @State private var selectedDNS: [DNSServer] = DNSServer.current + + var body: some View { + List { + SettingsSection(title: "DNS Server") { + ForEach(DNSServer.allCases.filter { $0.rawValue.hasSuffix(".14") || $0.rawValue.hasSuffix(".1") || $0.rawValue.hasSuffix(".8") }, id: \.self) { server in + Button(action: { + if let index = selectedDNS.firstIndex(of: server) { + selectedDNS.remove(at: index) + } else { + selectedDNS = [server] + if server == .cloudflare { + selectedDNS.append(.cloudflareSecondary) + } else if server == .adGuard { + selectedDNS.append(.adGuardSecondary) + } else if server == .google { + selectedDNS.append(.googleSecondary) + } + } + DNSConfiguration.shared.setDNSServer(selectedDNS) + }) { + HStack { + VStack(alignment: .leading) { + Text(server.rawValue) + Text(serverDescription(server)) + .font(.caption) + .foregroundColor(.gray) + } + Spacer() + if selectedDNS.contains(server) { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } + } + } + } + } + + Section(footer: Text("Using custom DNS servers can help protect your privacy and potentially block ads. Changes take effect immediately.")) {} + } + .navigationTitle("DNS Settings") + } + + private func serverDescription(_ server: DNSServer) -> String { + switch server { + case .cloudflare: + return "Cloudflare (Fast & Private)" + case .adGuard: + return "AdGuard (Ad Blocking)" + case .google: + return "Google (Reliable)" + default: + return "" + } + } +} diff --git a/Sora/Views/SettingsView/SettingsView.swift b/Sora/Views/SettingsView/SettingsView.swift index 51f191c..fc82df3 100644 --- a/Sora/Views/SettingsView/SettingsView.swift +++ b/Sora/Views/SettingsView/SettingsView.swift @@ -216,6 +216,11 @@ struct SettingsView: View { NavigationLink(destination: SettingsViewLogger()) { SettingsNavigationRow(icon: "doc.text", titleKey: "Logs") } + Divider().padding(.horizontal, 16) + + NavigationLink(destination: SettingsViewDNS()) { + SettingsNavigationRow(icon: "server.rack", titleKey: "DNS") + } } .background(.ultraThinMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index ba5a9f6..1677644 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -50,6 +50,8 @@ 133D7C932D2BE2640075467E /* Modules.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C892D2BE2640075467E /* Modules.swift */; }; 133D7C942D2BE2640075467E /* JSController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C8B2D2BE2640075467E /* JSController.swift */; }; 133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133F55BA2D33B55100E08EEA /* LibraryManager.swift */; }; + 134ECCA42DFD649D00372FCE /* DNSConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 134ECCA32DFD649D00372FCE /* DNSConfiguration.swift */; }; + 134ECCA82DFD655700372FCE /* SettingsViewDNS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 134ECCA72DFD655700372FCE /* SettingsViewDNS.swift */; }; 1359ED142D76F49900C13034 /* finTopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1359ED132D76F49900C13034 /* finTopView.swift */; }; 135CCBE22D4D1138008B9C0E /* SettingsViewPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */; }; 13637B8A2DE0EA1100BDA2FC /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13637B892DE0EA1100BDA2FC /* UserDefaults.swift */; }; @@ -145,6 +147,8 @@ 133D7C892D2BE2640075467E /* Modules.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Modules.swift; sourceTree = ""; }; 133D7C8B2D2BE2640075467E /* JSController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSController.swift; sourceTree = ""; }; 133F55BA2D33B55100E08EEA /* LibraryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryManager.swift; sourceTree = ""; }; + 134ECCA32DFD649D00372FCE /* DNSConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DNSConfiguration.swift; sourceTree = ""; }; + 134ECCA72DFD655700372FCE /* SettingsViewDNS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewDNS.swift; sourceTree = ""; }; 1359ED132D76F49900C13034 /* finTopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = finTopView.swift; sourceTree = ""; }; 135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewPlayer.swift; sourceTree = ""; }; 13637B892DE0EA1100BDA2FC /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = ""; }; @@ -378,6 +382,7 @@ 133D7C832D2BE2630075467E /* SettingsSubViews */ = { isa = PBXGroup; children = ( + 134ECCA72DFD655700372FCE /* SettingsViewDNS.swift */, 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */, 1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */, 133D7C842D2BE2630075467E /* SettingsViewModule.swift */, @@ -394,6 +399,7 @@ 133D7C852D2BE2640075467E /* Utils */ = { isa = PBXGroup; children = ( + 134ECCA22DFD649D00372FCE /* NetworkConfig */, 130326B42DF979A300AEF610 /* WebAuthentication */, 0457C5962DE7712A000AFBD9 /* ViewModifiers */, 04F08EE02DE10C22006B29D9 /* Models */, @@ -475,6 +481,14 @@ path = Downloads; sourceTree = ""; }; + 134ECCA22DFD649D00372FCE /* NetworkConfig */ = { + isa = PBXGroup; + children = ( + 134ECCA32DFD649D00372FCE /* DNSConfiguration.swift */, + ); + path = NetworkConfig; + sourceTree = ""; + }; 1384DCDF2D89BE870094797A /* Helpers */ = { isa = PBXGroup; children = ( @@ -770,6 +784,7 @@ 133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */, 13E62FC42DABC58C0007E259 /* Trakt-Token.swift in Sources */, 1398FB3F2DE4E161004D3F5F /* SettingsViewAbout.swift in Sources */, + 134ECCA82DFD655700372FCE /* SettingsViewDNS.swift in Sources */, 133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */, 13E62FC72DABFE900007E259 /* TraktPushUpdates.swift in Sources */, 133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */, @@ -791,6 +806,7 @@ 7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */, 722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */, 13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */, + 134ECCA42DFD649D00372FCE /* DNSConfiguration.swift in Sources */, 13637B8A2DE0EA1100BDA2FC /* UserDefaults.swift in Sources */, 0457C59D2DE78267000AFBD9 /* BookmarkGridView.swift in Sources */, 0457C59E2DE78267000AFBD9 /* BookmarkLink.swift in Sources */, From c42d53f8f544b521f01a60852d454782206ca743 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Sat, 14 Jun 2025 10:24:27 +0200 Subject: [PATCH 48/52] Revert "maybe DNS" This reverts commit 63b1b20f5af6fa81c420ffb3ea6fc721f08c027b. --- Sora/Utils/Extensions/URLSession.swift | 3 +- .../NetworkConfig/DNSConfiguration.swift | 47 ------- .../SettingsSubViews/SettingsViewDNS.swift | 116 ------------------ Sora/Views/SettingsView/SettingsView.swift | 5 - Sulfur.xcodeproj/project.pbxproj | 16 --- 5 files changed, 1 insertion(+), 186 deletions(-) delete mode 100644 Sora/Utils/NetworkConfig/DNSConfiguration.swift delete mode 100644 Sora/Views/SettingsView/SettingsSubViews/SettingsViewDNS.swift diff --git a/Sora/Utils/Extensions/URLSession.swift b/Sora/Utils/Extensions/URLSession.swift index 7fae451..d1cacb7 100644 --- a/Sora/Utils/Extensions/URLSession.swift +++ b/Sora/Utils/Extensions/URLSession.swift @@ -60,8 +60,7 @@ extension URLSession { static let custom: URLSession = { let configuration = URLSessionConfiguration.default configuration.httpAdditionalHeaders = ["User-Agent": randomUserAgent] - let session = URLSession(configuration: configuration) - return DNSConfiguration.shared.configureDNS(for: session) + return URLSession(configuration: configuration) }() static func fetchData(allowRedirects:Bool) -> URLSession diff --git a/Sora/Utils/NetworkConfig/DNSConfiguration.swift b/Sora/Utils/NetworkConfig/DNSConfiguration.swift deleted file mode 100644 index f38dd38..0000000 --- a/Sora/Utils/NetworkConfig/DNSConfiguration.swift +++ /dev/null @@ -1,47 +0,0 @@ -// -// DNSConfiguration.swift -// Sulfur -// -// Created by Francesco on 14/06/25. -// - -import Network -import Foundation - -enum DNSServer: String, CaseIterable { - case cloudflare = "1.1.1.1" - case cloudflareSecondary = "1.0.0.1" - case adGuard = "94.140.14.14" - case adGuardSecondary = "94.140.15.15" - case google = "8.8.8.8" - case googleSecondary = "8.8.4.4" - - static var current: [DNSServer] = [.cloudflare, .cloudflareSecondary] -} - -class DNSConfiguration { - static let shared = DNSConfiguration() - - private init() {} - - func configureDNS(for session: URLSession) -> URLSession { - let configuration = (session.configuration.copy() as! URLSessionConfiguration) - - let proxyDict: [AnyHashable: Any] = [ - kCFProxyTypeKey: kCFProxyTypeHTTPS, - kCFProxyHostNameKey: DNSServer.current[0].rawValue, - kCFProxyPortNumberKey: 443, - kCFProxyUsernameKey: "", - kCFProxyPasswordKey: "" - ] - - configuration.connectionProxyDictionary = proxyDict - - return URLSession(configuration: configuration, delegate: session.delegate, delegateQueue: session.delegateQueue) - } - - func setDNSServer(_ servers: [DNSServer]) { - DNSServer.current = servers - URLSession.shared.invalidateAndCancel() - } -} diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewDNS.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewDNS.swift deleted file mode 100644 index 8d3e545..0000000 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewDNS.swift +++ /dev/null @@ -1,116 +0,0 @@ -// -// SettingsViewDNS.swift -// Sulfur -// -// Created by Francesco on 14/06/25. -// - -import SwiftUI - -fileprivate struct SettingsSection: View { - let title: String - let footer: String? - let content: Content - - init(title: String, footer: String? = nil, @ViewBuilder content: () -> Content) { - self.title = title - self.footer = footer - self.content = content() - } - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(title.uppercased()) - .font(.footnote) - .foregroundStyle(.gray) - .padding(.horizontal, 20) - - VStack(spacing: 0) { - content - } - .background(.ultraThinMaterial) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .overlay( - RoundedRectangle(cornerRadius: 12) - .strokeBorder( - LinearGradient( - gradient: Gradient(stops: [ - .init(color: Color.accentColor.opacity(0.3), location: 0), - .init(color: Color.accentColor.opacity(0), location: 1) - ]), - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 0.5 - ) - ) - .padding(.horizontal, 20) - - if let footer = footer { - Text(footer) - .font(.footnote) - .foregroundStyle(.gray) - .padding(.horizontal, 20) - .padding(.top, 4) - } - } - } -} - -struct SettingsViewDNS: View { - @State private var selectedDNS: [DNSServer] = DNSServer.current - - var body: some View { - List { - SettingsSection(title: "DNS Server") { - ForEach(DNSServer.allCases.filter { $0.rawValue.hasSuffix(".14") || $0.rawValue.hasSuffix(".1") || $0.rawValue.hasSuffix(".8") }, id: \.self) { server in - Button(action: { - if let index = selectedDNS.firstIndex(of: server) { - selectedDNS.remove(at: index) - } else { - selectedDNS = [server] - if server == .cloudflare { - selectedDNS.append(.cloudflareSecondary) - } else if server == .adGuard { - selectedDNS.append(.adGuardSecondary) - } else if server == .google { - selectedDNS.append(.googleSecondary) - } - } - DNSConfiguration.shared.setDNSServer(selectedDNS) - }) { - HStack { - VStack(alignment: .leading) { - Text(server.rawValue) - Text(serverDescription(server)) - .font(.caption) - .foregroundColor(.gray) - } - Spacer() - if selectedDNS.contains(server) { - Image(systemName: "checkmark") - .foregroundColor(.accentColor) - } - } - } - } - } - - Section(footer: Text("Using custom DNS servers can help protect your privacy and potentially block ads. Changes take effect immediately.")) {} - } - .navigationTitle("DNS Settings") - } - - private func serverDescription(_ server: DNSServer) -> String { - switch server { - case .cloudflare: - return "Cloudflare (Fast & Private)" - case .adGuard: - return "AdGuard (Ad Blocking)" - case .google: - return "Google (Reliable)" - default: - return "" - } - } -} diff --git a/Sora/Views/SettingsView/SettingsView.swift b/Sora/Views/SettingsView/SettingsView.swift index fc82df3..51f191c 100644 --- a/Sora/Views/SettingsView/SettingsView.swift +++ b/Sora/Views/SettingsView/SettingsView.swift @@ -216,11 +216,6 @@ struct SettingsView: View { NavigationLink(destination: SettingsViewLogger()) { SettingsNavigationRow(icon: "doc.text", titleKey: "Logs") } - Divider().padding(.horizontal, 16) - - NavigationLink(destination: SettingsViewDNS()) { - SettingsNavigationRow(icon: "server.rack", titleKey: "DNS") - } } .background(.ultraThinMaterial) .clipShape(RoundedRectangle(cornerRadius: 12)) diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index 1677644..ba5a9f6 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -50,8 +50,6 @@ 133D7C932D2BE2640075467E /* Modules.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C892D2BE2640075467E /* Modules.swift */; }; 133D7C942D2BE2640075467E /* JSController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C8B2D2BE2640075467E /* JSController.swift */; }; 133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133F55BA2D33B55100E08EEA /* LibraryManager.swift */; }; - 134ECCA42DFD649D00372FCE /* DNSConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 134ECCA32DFD649D00372FCE /* DNSConfiguration.swift */; }; - 134ECCA82DFD655700372FCE /* SettingsViewDNS.swift in Sources */ = {isa = PBXBuildFile; fileRef = 134ECCA72DFD655700372FCE /* SettingsViewDNS.swift */; }; 1359ED142D76F49900C13034 /* finTopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1359ED132D76F49900C13034 /* finTopView.swift */; }; 135CCBE22D4D1138008B9C0E /* SettingsViewPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */; }; 13637B8A2DE0EA1100BDA2FC /* UserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13637B892DE0EA1100BDA2FC /* UserDefaults.swift */; }; @@ -147,8 +145,6 @@ 133D7C892D2BE2640075467E /* Modules.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Modules.swift; sourceTree = ""; }; 133D7C8B2D2BE2640075467E /* JSController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSController.swift; sourceTree = ""; }; 133F55BA2D33B55100E08EEA /* LibraryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryManager.swift; sourceTree = ""; }; - 134ECCA32DFD649D00372FCE /* DNSConfiguration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DNSConfiguration.swift; sourceTree = ""; }; - 134ECCA72DFD655700372FCE /* SettingsViewDNS.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsViewDNS.swift; sourceTree = ""; }; 1359ED132D76F49900C13034 /* finTopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = finTopView.swift; sourceTree = ""; }; 135CCBE12D4D1138008B9C0E /* SettingsViewPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewPlayer.swift; sourceTree = ""; }; 13637B892DE0EA1100BDA2FC /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = ""; }; @@ -382,7 +378,6 @@ 133D7C832D2BE2630075467E /* SettingsSubViews */ = { isa = PBXGroup; children = ( - 134ECCA72DFD655700372FCE /* SettingsViewDNS.swift */, 1E9FF1D22D403E42008AC100 /* SettingsViewLoggerFilter.swift */, 1399FAD32D3AB38C00E97C31 /* SettingsViewLogger.swift */, 133D7C842D2BE2630075467E /* SettingsViewModule.swift */, @@ -399,7 +394,6 @@ 133D7C852D2BE2640075467E /* Utils */ = { isa = PBXGroup; children = ( - 134ECCA22DFD649D00372FCE /* NetworkConfig */, 130326B42DF979A300AEF610 /* WebAuthentication */, 0457C5962DE7712A000AFBD9 /* ViewModifiers */, 04F08EE02DE10C22006B29D9 /* Models */, @@ -481,14 +475,6 @@ path = Downloads; sourceTree = ""; }; - 134ECCA22DFD649D00372FCE /* NetworkConfig */ = { - isa = PBXGroup; - children = ( - 134ECCA32DFD649D00372FCE /* DNSConfiguration.swift */, - ); - path = NetworkConfig; - sourceTree = ""; - }; 1384DCDF2D89BE870094797A /* Helpers */ = { isa = PBXGroup; children = ( @@ -784,7 +770,6 @@ 133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */, 13E62FC42DABC58C0007E259 /* Trakt-Token.swift in Sources */, 1398FB3F2DE4E161004D3F5F /* SettingsViewAbout.swift in Sources */, - 134ECCA82DFD655700372FCE /* SettingsViewDNS.swift in Sources */, 133D7C8E2D2BE2640075467E /* LibraryView.swift in Sources */, 13E62FC72DABFE900007E259 /* TraktPushUpdates.swift in Sources */, 133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */, @@ -806,7 +791,6 @@ 7222485F2DCBAA2C00CABE2D /* DownloadModels.swift in Sources */, 722248602DCBAA2C00CABE2D /* M3U8StreamExtractor.swift in Sources */, 13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */, - 134ECCA42DFD649D00372FCE /* DNSConfiguration.swift in Sources */, 13637B8A2DE0EA1100BDA2FC /* UserDefaults.swift in Sources */, 0457C59D2DE78267000AFBD9 /* BookmarkGridView.swift in Sources */, 0457C59E2DE78267000AFBD9 /* BookmarkLink.swift in Sources */, From ffeddb37e63201e50ae74eb7c55de57cc063b1db Mon Sep 17 00:00:00 2001 From: Seiike <122684677+Seeike@users.noreply.github.com> Date: Sat, 14 Jun 2025 15:50:53 +0200 Subject: [PATCH 49/52] instead of matched id being an int now its the actual name of the series (#190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * removed double bs for id telling * improved anilist logic, single episode anilist sync, anilist sync also avaiaiable with tmdb as provider, tmdb posters avaiabale with anilist as provider * instead of telling the id of the match now it tells the name * gotta release a testflight 🙏 --- .../AnilistMatchPopupView.swift | 92 +++++++++---------- .../CustomMatching/TMDBMatchPopupView.swift | 32 +++---- Sora/Views/MediaInfoView/MediaInfoView.swift | 25 +++-- 3 files changed, 67 insertions(+), 82 deletions(-) diff --git a/Sora/Views/MediaInfoView/CustomMatching/AnilistMatchPopupView.swift b/Sora/Views/MediaInfoView/CustomMatching/AnilistMatchPopupView.swift index 975736c..8e05e38 100644 --- a/Sora/Views/MediaInfoView/CustomMatching/AnilistMatchPopupView.swift +++ b/Sora/Views/MediaInfoView/CustomMatching/AnilistMatchPopupView.swift @@ -1,33 +1,32 @@ // -// AnilistMatchPopupView.swift -// Sulfur -// -// Created by seiike on 01/06/2025. +// AnilistMatchPopupView.swift +// Sulfur // +// Created by seiike on 01/06/2025. import NukeUI import SwiftUI struct AnilistMatchPopupView: View { let seriesTitle: String - let onSelect: (Int) -> Void - + let onSelect: (Int, String) -> Void + @State private var results: [[String: Any]] = [] @State private var isLoading = true - + @AppStorage("selectedAppearance") private var selectedAppearance: Appearance = .system @Environment(\.colorScheme) private var colorScheme - + private var isLightMode: Bool { selectedAppearance == .light - || (selectedAppearance == .system && colorScheme == .light) + || (selectedAppearance == .system && colorScheme == .light) } - + @State private var manualIDText: String = "" @State private var showingManualIDAlert = false - + @Environment(\.dismiss) private var dismiss - + var body: some View { NavigationView { ScrollView { @@ -36,7 +35,7 @@ struct AnilistMatchPopupView: View { .font(.footnote) .foregroundStyle(.gray) .padding(.horizontal, 10) - + VStack(spacing: 0) { if isLoading { ProgressView() @@ -52,10 +51,11 @@ struct AnilistMatchPopupView: View { LazyVStack(spacing: 15) { ForEach(results.indices, id: \.self) { index in let result = results[index] - Button(action: { if let id = result["id"] as? Int { - onSelect(id) + let title = result["title"] as? String ?? seriesTitle + onSelect(id, title) + dismiss() } }) { HStack(spacing: 12) { @@ -76,19 +76,18 @@ struct AnilistMatchPopupView: View { } } } - + VStack(alignment: .leading, spacing: 2) { Text(result["title"] as? String ?? "Unknown") .font(.body) .foregroundStyle(.primary) - if let english = result["title_english"] as? String { Text(english) .font(.caption) .foregroundStyle(.secondary) } } - + Spacer() } .padding(11) @@ -120,7 +119,7 @@ struct AnilistMatchPopupView: View { .padding(.top, 16) } } - + if !results.isEmpty { Text("Tap a title to override the current match.") .font(.footnote) @@ -135,38 +134,36 @@ struct AnilistMatchPopupView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } - .foregroundColor(isLightMode ? .black : .white) + Button("Cancel") { dismiss() } + .foregroundColor(isLightMode ? .black : .white) } ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { + Button { manualIDText = "" showingManualIDAlert = true - }) { + } label: { Image(systemName: "number") .foregroundColor(isLightMode ? .black : .white) } } } - .alert("Set Custom AniList ID", isPresented: $showingManualIDAlert, actions: { + .alert("Set Custom AniList ID", isPresented: $showingManualIDAlert) { TextField("AniList ID", text: $manualIDText) .keyboardType(.numberPad) Button("Cancel", role: .cancel) { } - Button("Save", action: { + Button("Save") { if let idInt = Int(manualIDText.trimmingCharacters(in: .whitespaces)) { - onSelect(idInt) + onSelect(idInt, seriesTitle) dismiss() } - }) - }, message: { + } + } message: { Text("Enter the AniList ID for this series") - }) + } } .onAppear(perform: fetchMatches) } - + private func fetchMatches() { let query = """ query { @@ -184,35 +181,32 @@ struct AnilistMatchPopupView: View { } } """ - + guard let url = URL(string: "https://graphql.anilist.co") else { return } - var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try? JSONSerialization.data(withJSONObject: ["query": query]) - + URLSession.shared.dataTask(with: request) { data, _, _ in DispatchQueue.main.async { - self.isLoading = false - - guard let data = data, - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let dataDict = json["data"] as? [String: Any], - let page = dataDict["Page"] as? [String: Any], - let mediaList = page["media"] as? [[String: Any]] else { - return - } - - self.results = mediaList.map { media in + isLoading = false + guard + let data = data, + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let dataDict = json["data"] as? [String: Any], + let page = dataDict["Page"] as? [String: Any], + let mediaList = page["media"] as? [[String: Any]] + else { return } + + results = mediaList.map { media in let titleInfo = media["title"] as? [String: Any] let cover = (media["coverImage"] as? [String: Any])?["large"] as? String - return [ "id": media["id"] ?? 0, "title": titleInfo?["romaji"] ?? "Unknown", - "title_english": titleInfo?["english"], - "cover": cover + "title_english": titleInfo?["english"] as Any, + "cover": cover as Any ] } } diff --git a/Sora/Views/MediaInfoView/CustomMatching/TMDBMatchPopupView.swift b/Sora/Views/MediaInfoView/CustomMatching/TMDBMatchPopupView.swift index 3293a2a..d34bc69 100644 --- a/Sora/Views/MediaInfoView/CustomMatching/TMDBMatchPopupView.swift +++ b/Sora/Views/MediaInfoView/CustomMatching/TMDBMatchPopupView.swift @@ -1,16 +1,15 @@ // -// TMDBMatchPopupView.swift -// Sulfur -// -// Created by seiike on 12/06/2025. +// TMDBMatchPopupView.swift +// Sulfur // +// Created by seiike on 12/06/2025. import SwiftUI import NukeUI struct TMDBMatchPopupView: View { let seriesTitle: String - let onSelect: (Int, TMDBFetcher.MediaType) -> Void + let onSelect: (Int, TMDBFetcher.MediaType, String) -> Void @State private var results: [ResultItem] = [] @State private var isLoading = true @@ -54,10 +53,10 @@ struct TMDBMatchPopupView: View { } else { LazyVStack(spacing: 15) { ForEach(results) { item in - Button(action: { - onSelect(item.id, item.mediaType) + Button { + onSelect(item.id, item.mediaType, item.title) dismiss() - }) { + } label: { HStack(spacing: 12) { if let poster = item.posterURL, let url = URL(string: poster) { LazyImage(url: url) { state in @@ -112,9 +111,7 @@ struct TMDBMatchPopupView: View { .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { - Button("Cancel") { - dismiss() - } + Button("Cancel") { dismiss() } } } .alert("Error Fetching Results", isPresented: $showingError) { @@ -129,7 +126,6 @@ struct TMDBMatchPopupView: View { private func fetchMatches() { isLoading = true results = [] - let fetcher = TMDBFetcher() let apiKey = fetcher.apiKey let dispatchGroup = DispatchGroup() @@ -148,9 +144,10 @@ struct TMDBMatchPopupView: View { URLSession.shared.dataTask(with: url) { data, _, error in defer { dispatchGroup.leave() } - - guard error == nil, let data = data, - let response = try? JSONDecoder().decode(TMDBSearchResponse.self, from: data) else { + guard error == nil, + let data = data, + let response = try? JSONDecoder().decode(TMDBSearchResponse.self, from: data) + else { encounteredError = true return } @@ -165,10 +162,7 @@ struct TMDBMatchPopupView: View { } dispatchGroup.notify(queue: .main) { - if encounteredError { - showingError = true - } - // Keep API order (by popularity), limit to top 6 overall + if encounteredError { showingError = true } results = Array(temp.prefix(6)) isLoading = false } diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index a4f5cb6..31d847d 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -655,14 +655,17 @@ struct MediaInfoView: View { .circularGradientOutline() } .sheet(isPresented: $isMatchingPresented) { - AnilistMatchPopupView(seriesTitle: title) { selectedID in - handleAniListMatch(selectedID: selectedID) + AnilistMatchPopupView(seriesTitle: title) { id, matched in + handleAniListMatch(selectedID: id) + matchedTitle = matched // ← now in scope fetchMetadataIDIfNeeded() } } .sheet(isPresented: $isTMDBMatchingPresented) { - TMDBMatchPopupView(seriesTitle: title) { id, type in - tmdbID = id; tmdbType = type + TMDBMatchPopupView(seriesTitle: title) { id, type, matched in + tmdbID = id + tmdbType = type + matchedTitle = matched // ← now in scope fetchMetadataIDIfNeeded() } } @@ -671,18 +674,12 @@ struct MediaInfoView: View { @ViewBuilder private var menuContent: some View { Group { - if let active = activeProvider { - Text("Provider: \(active)") - .font(.caption) - .foregroundColor(.gray) - .padding(.vertical, 4) - Divider() + if let provider = activeProvider { + Text("Matched \(provider): \(matchedTitle ?? title)") + .font(.caption2) + .foregroundColor(.secondary) } - Text("Matched ID: \(itemID ?? 0)") - .font(.caption2) - .foregroundColor(.secondary) - if activeProvider == "AniList" { Button("Match with AniList") { isMatchingPresented = true From 51dcae1a54eef9c102b8c0b2ee363a3e8cc86b7a Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Sat, 14 Jun 2025 16:19:10 +0200 Subject: [PATCH 50/52] =?UTF-8?q?please=20trakt=20please=20=F0=9F=99=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Trakt/Mutations/TraktPushUpdates.swift | 59 ++++++--- .../CustomPlayer/CustomPlayer.swift | 20 ++- Sora/Utils/MediaPlayer/VideoPlayer.swift | 19 ++- Sora/Views/MediaInfoView/MediaInfoView.swift | 124 +++++++++--------- Sora/Views/SplashScreenView.swift | 7 +- 5 files changed, 138 insertions(+), 91 deletions(-) diff --git a/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift b/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift index 53ba1a8..4c2e0fa 100644 --- a/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift +++ b/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift @@ -25,29 +25,36 @@ class TraktMutation { guard status == errSecSuccess, let tokenData = item as? Data, let token = String(data: tokenData, encoding: .utf8) else { - return nil - } + return nil + } return token } func markAsWatched(type: String, tmdbID: Int, episodeNumber: Int? = nil, seasonNumber: Int? = nil, completion: @escaping (Result) -> Void) { - if let sendTraktUpdates = UserDefaults.standard.object(forKey: "sendTraktUpdates") as? Bool, - sendTraktUpdates == false { + let sendTraktUpdates = UserDefaults.standard.object(forKey: "sendTraktUpdates") as? Bool ?? true + if !sendTraktUpdates { + Logger.shared.log("Trakt updates disabled by user preference", type: "Debug") + completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Trakt updates disabled by user"]))) return } + Logger.shared.log("Attempting to mark \(type) as watched - TMDB ID: \(tmdbID), Episode: \(episodeNumber ?? 0), Season: \(seasonNumber ?? 0)", type: "Debug") + guard let userToken = getTokenFromKeychain() else { + Logger.shared.log("Trakt access token not found in keychain", type: "Error") completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Access token not found"]))) - Logger.shared.log("Trakt Access token not found", type: "Error") return } + Logger.shared.log("Found Trakt access token, proceeding with API call", type: "Debug") + let endpoint = "/sync/history" let watchedAt = ISO8601DateFormatter().string(from: Date()) let body: [String: Any] switch type { case "movie": + Logger.shared.log("Preparing movie watch request for TMDB ID: \(tmdbID)", type: "Debug") body = [ "movies": [ [ @@ -59,10 +66,13 @@ class TraktMutation { case "episode": guard let episode = episodeNumber, let season = seasonNumber else { - completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Missing episode or season number"]))) + let errorMsg = "Missing episode (\(episodeNumber ?? -1)) or season (\(seasonNumber ?? -1)) number" + Logger.shared.log(errorMsg, type: "Error") + completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: errorMsg]))) return } + Logger.shared.log("Preparing episode watch request - TMDB ID: \(tmdbID), Season: \(season), Episode: \(episode)", type: "Debug") body = [ "shows": [ [ @@ -83,6 +93,7 @@ class TraktMutation { ] default: + Logger.shared.log("Invalid content type: \(type)", type: "Error") completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid content type"]))) return } @@ -95,36 +106,54 @@ class TraktMutation { request.setValue(TraktToken.clientID, forHTTPHeaderField: "trakt-api-key") do { - request.httpBody = try JSONSerialization.data(withJSONObject: body, options: [.prettyPrinted]) + let jsonData = try JSONSerialization.data(withJSONObject: body, options: [.prettyPrinted]) + request.httpBody = jsonData + + if let jsonString = String(data: jsonData, encoding: .utf8) { + Logger.shared.log("Trakt API Request Body: \(jsonString)", type: "Debug") + } } catch { + Logger.shared.log("Failed to serialize request body: \(error.localizedDescription)", type: "Error") completion(.failure(error)) return } + Logger.shared.log("Sending Trakt API request to: \(request.url?.absoluteString ?? "unknown")", type: "Debug") + let task = URLSession.shared.dataTask(with: request) { data, response, error in if let error = error { + Logger.shared.log("Trakt API network error: \(error.localizedDescription)", type: "Error") completion(.failure(error)) return } guard let httpResponse = response as? HTTPURLResponse else { + Logger.shared.log("Trakt API: No HTTP response received", type: "Error") completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "No HTTP response"]))) return } + Logger.shared.log("Trakt API Response Status: \(httpResponse.statusCode)", type: "Debug") + + if let data = data, let responseString = String(data: data, encoding: .utf8) { + Logger.shared.log("Trakt API Response Body: \(responseString)", type: "Debug") + } + if (200...299).contains(httpResponse.statusCode) { - if let data = data, let responseString = String(data: data, encoding: .utf8) { - Logger.shared.log("Trakt API Response: \(responseString)", type: "Debug") - } - Logger.shared.log("Successfully updated watch status on Trakt", type: "Debug") + Logger.shared.log("Successfully updated watch status on Trakt for \(type)", type: "General") completion(.success(())) } else { - var errorMessage = "Unexpected status code: \(httpResponse.statusCode)" + var errorMessage = "HTTP \(httpResponse.statusCode)" if let data = data, - let errorJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let error = errorJson["error"] as? String { - errorMessage = error + let errorJson = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + if let error = errorJson["error"] as? String { + errorMessage = "\(errorMessage): \(error)" + } + if let errorDescription = errorJson["error_description"] as? String { + errorMessage = "\(errorMessage) - \(errorDescription)" + } } + Logger.shared.log("Trakt API Error: \(errorMessage)", type: "Error") completion(.failure(NSError(domain: "", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: errorMessage]))) } } diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index 07fbf25..bf6b098 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -1647,19 +1647,26 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self.tryAniListUpdate() } - if let tmdbId = self.tmdbID { + if let tmdbId = self.tmdbID, tmdbId > 0 { + Logger.shared.log("Attempting Trakt update - TMDB ID: \(tmdbId), isMovie: \(self.isMovie), episode: \(self.episodeNumber), season: \(self.seasonNumber)", type: "Debug") + let traktMutation = TraktMutation() if self.isMovie { traktMutation.markAsWatched(type: "movie", tmdbID: tmdbId) { result in switch result { case .success: - Logger.shared.log("Successfully updated Trakt progress for movie", type: "General") + Logger.shared.log("Successfully updated Trakt progress for movie (TMDB: \(tmdbId))", type: "General") case .failure(let error): - Logger.shared.log("Failed to update Trakt progress: \(error.localizedDescription)", type: "Error") + Logger.shared.log("Failed to update Trakt progress for movie: \(error.localizedDescription)", type: "Error") } } } else { + guard self.episodeNumber > 0 && self.seasonNumber > 0 else { + Logger.shared.log("Invalid episode (\(self.episodeNumber)) or season (\(self.seasonNumber)) number for Trakt update", type: "Error") + return + } + traktMutation.markAsWatched( type: "episode", tmdbID: tmdbId, @@ -1668,12 +1675,14 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele ) { result in switch result { case .success: - Logger.shared.log("Successfully updated Trakt progress for episode \(self.episodeNumber)", type: "General") + Logger.shared.log("Successfully updated Trakt progress for episode \(self.episodeNumber) (TMDB: \(tmdbId))", type: "General") case .failure(let error): - Logger.shared.log("Failed to update Trakt progress: \(error.localizedDescription)", type: "Error") + Logger.shared.log("Failed to update Trakt progress for episode: \(error.localizedDescription)", type: "Error") } } } + } else { + Logger.shared.log("Skipping Trakt update - TMDB ID not set or invalid: \(self.tmdbID ?? -1)", type: "Warning") } } @@ -1831,6 +1840,7 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele @objc func seekForward() { let skipValue = UserDefaults.standard.double(forKey: "skipIncrement") + let finalSkip = skipValue > 0 ? skipValue : 10 currentTimeVal = min(currentTimeVal + finalSkip, duration) player.seek(to: CMTime(seconds: currentTimeVal, preferredTimescale: 600)) { [weak self] finished in diff --git a/Sora/Utils/MediaPlayer/VideoPlayer.swift b/Sora/Utils/MediaPlayer/VideoPlayer.swift index bd65523..7d182de 100644 --- a/Sora/Utils/MediaPlayer/VideoPlayer.swift +++ b/Sora/Utils/MediaPlayer/VideoPlayer.swift @@ -215,19 +215,26 @@ class VideoPlayerViewController: UIViewController { } } - if let tmdbId = self.tmdbID { + if let tmdbId = self.tmdbID, tmdbId > 0 { + Logger.shared.log("Attempting Trakt update - TMDB ID: \(tmdbId), isMovie: \(self.isMovie), episode: \(self.episodeNumber), season: \(self.seasonNumber)", type: "Debug") + let traktMutation = TraktMutation() if self.isMovie { traktMutation.markAsWatched(type: "movie", tmdbID: tmdbId) { result in switch result { case .success: - Logger.shared.log("Updated Trakt progress for movie", type: "General") + Logger.shared.log("Updated Trakt progress for movie (TMDB: \(tmdbId))", type: "General") case .failure(let error): - Logger.shared.log("Could not update Trakt progress: \(error.localizedDescription)", type: "Error") + Logger.shared.log("Could not update Trakt progress for movie: \(error.localizedDescription)", type: "Error") } } } else { + guard self.episodeNumber > 0 && self.seasonNumber > 0 else { + Logger.shared.log("Invalid episode (\(self.episodeNumber)) or season (\(self.seasonNumber)) number for Trakt update", type: "Error") + return + } + traktMutation.markAsWatched( type: "episode", tmdbID: tmdbId, @@ -236,12 +243,14 @@ class VideoPlayerViewController: UIViewController { ) { result in switch result { case .success: - Logger.shared.log("Updated Trakt progress for Episode \(self.episodeNumber)", type: "General") + Logger.shared.log("Updated Trakt progress for Episode \(self.episodeNumber) (TMDB: \(tmdbId))", type: "General") case .failure(let error): - Logger.shared.log("Could not update Trakt progress: \(error.localizedDescription)", type: "Error") + Logger.shared.log("Could not update Trakt progress for episode: \(error.localizedDescription)", type: "Error") } } } + } else { + Logger.shared.log("Skipping Trakt update - TMDB ID not set or invalid: \(self.tmdbID ?? -1)", type: "Warning") } } } diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 31d847d..66155ba 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -804,12 +804,12 @@ struct MediaInfoView: View { let total = UserDefaults.standard.double(forKey: totalTimeKey) let progress = total > 0 ? last/total : 0 let watchedEp = ep.number - + if progress <= 0.9 { UserDefaults.standard.set(99999999.0, forKey: lastPlayedKey) UserDefaults.standard.set(99999999.0, forKey: totalTimeKey) DropManager.shared.showDrop(title: "Marked as Watched", subtitle: "", duration: 1.0, icon: UIImage(systemName: "checkmark.circle.fill")) - + if let listID = itemID, listID > 0 { AniListMutation().updateAnimeProgress(animeId: listID, episodeNumber: watchedEp, status: "CURRENT") { result in switch result { @@ -824,7 +824,7 @@ struct MediaInfoView: View { UserDefaults.standard.set(0.0, forKey: lastPlayedKey) UserDefaults.standard.set(0.0, forKey: totalTimeKey) DropManager.shared.showDrop(title: "Progress Reset", subtitle: "", duration: 1.0, icon: UIImage(systemName: "arrow.counterclockwise")) - + if let listID = itemID, listID > 0 { AniListMutation().updateAnimeProgress(animeId: listID, episodeNumber: 0, status: "CURRENT") { _ in } } @@ -1204,7 +1204,6 @@ struct MediaInfoView: View { } } } - private func fetchAniListIDForSync() { let cleaned = cleanTitle(title) @@ -1225,76 +1224,73 @@ struct MediaInfoView: View { func fetchMetadataIDIfNeeded() { let order = metadataProvidersOrder let cleanedTitle = cleanTitle(title) - + itemID = nil tmdbID = nil activeProvider = nil isError = false - - func fetchAniList(completion: @escaping (Bool) -> Void) { - fetchItemID(byTitle: cleanedTitle) { result in + + var aniListCompleted = false + var tmdbCompleted = false + var aniListSuccess = false + var tmdbSuccess = false + + func checkCompletion() { + guard aniListCompleted && tmdbCompleted else { return } + + let primaryProvider = order.first ?? "AniList" + + if primaryProvider == "AniList" && aniListSuccess { + activeProvider = "AniList" + UserDefaults.standard.set("AniList", forKey: "metadataProviders") + } else if primaryProvider == "TMDB" && tmdbSuccess { + activeProvider = "TMDB" + UserDefaults.standard.set("TMDB", forKey: "metadataProviders") + } else if aniListSuccess { + activeProvider = "AniList" + UserDefaults.standard.set("AniList", forKey: "metadataProviders") + } else if tmdbSuccess { + activeProvider = "TMDB" + UserDefaults.standard.set("TMDB", forKey: "metadataProviders") + } else { + isError = true + } + } + + fetchItemID(byTitle: cleanedTitle) { result in + DispatchQueue.main.async { + aniListCompleted = true switch result { case .success(let id): - DispatchQueue.main.async { - self.itemID = id - self.activeProvider = "AniList" - UserDefaults.standard.set("AniList", forKey: "metadataProviders") - - tmdbFetcher.fetchBestMatchID(for: cleanedTitle) { tmdbId, tmdbType in - DispatchQueue.main.async { - guard let tmdbId = tmdbId, let tmdbType = tmdbType else { - completion(true) - return - } - self.tmdbID = tmdbId - self.tmdbType = tmdbType - self.fetchTMDBPosterImageAndSet() - completion(true) - } - } - } - + self.itemID = id + aniListSuccess = true + Logger.shared.log("Successfully fetched AniList ID: \(id)", type: "Debug") case .failure(let error): - Logger.shared.log("Failed to fetch AniList ID for tracking: \(error)", type: "Error") - completion(false) + Logger.shared.log("Failed to fetch AniList ID: \(error)", type: "Debug") } + checkCompletion() } } - - func tryProviders(_ index: Int) { - guard index < order.count else { - isError = true - return - } - - let provider = order[index] - switch provider { - case "AniList": - fetchAniList { success in - if !success { - tryProviders(index + 1) + + tmdbFetcher.fetchBestMatchID(for: cleanedTitle) { id, type in + DispatchQueue.main.async { + tmdbCompleted = true + if let id = id, let type = type { + self.tmdbID = id + self.tmdbType = type + tmdbSuccess = true + Logger.shared.log("Successfully fetched TMDB ID: \(id) (type: \(type.rawValue))", type: "Debug") + + if self.activeProvider != "TMDB" { + self.fetchTMDBPosterImageAndSet() } + } else { + Logger.shared.log("Failed to fetch TMDB ID", type: "Debug") } - case "TMDB": - tmdbFetcher.fetchBestMatchID(for: cleanedTitle) { id, type in - DispatchQueue.main.async { - if let id = id, let type = type { - self.tmdbID = id - self.tmdbType = type - self.activeProvider = "TMDB" - UserDefaults.standard.set("TMDB", forKey: "metadataProviders") - self.fetchTMDBPosterImageAndSet() - } else { - tryProviders(index + 1) - } - } - } - default: - tryProviders(index + 1) + checkCompletion() } } - - tryProviders(0) + fetchAniListIDForSync() } @@ -1560,6 +1556,8 @@ struct MediaInfoView: View { } private func presentDefaultPlayer(url: String, fullURL: String, subtitles: String?, headers: [String:String]?) { + let isMovie = tmdbType == .movie + let videoPlayerViewController = VideoPlayerViewController(module: module) videoPlayerViewController.headers = headers videoPlayerViewController.streamUrl = url @@ -1570,6 +1568,9 @@ struct MediaInfoView: View { videoPlayerViewController.mediaTitle = title videoPlayerViewController.subtitles = subtitles ?? "" videoPlayerViewController.aniListID = itemID ?? 0 + videoPlayerViewController.tmdbID = tmdbID + videoPlayerViewController.isMovie = isMovie + videoPlayerViewController.seasonNumber = selectedSeason + 1 videoPlayerViewController.modalPresentationStyle = .fullScreen if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, @@ -1589,6 +1590,7 @@ struct MediaInfoView: View { } guard self.activeFetchID == fetchID else { return } + let isMovie = tmdbType == .movie let customMediaPlayer = CustomMediaPlayerViewController( module: module, @@ -1604,6 +1606,8 @@ struct MediaInfoView: View { headers: headers ?? nil ) customMediaPlayer.seasonNumber = selectedSeason + 1 + customMediaPlayer.tmdbID = tmdbID + customMediaPlayer.isMovie = isMovie customMediaPlayer.modalPresentationStyle = .fullScreen Logger.shared.log("Opening custom media player with url: \(url)") diff --git a/Sora/Views/SplashScreenView.swift b/Sora/Views/SplashScreenView.swift index 01ec336..af37f9f 100644 --- a/Sora/Views/SplashScreenView.swift +++ b/Sora/Views/SplashScreenView.swift @@ -24,18 +24,13 @@ struct SplashScreenView: View { .cornerRadius(24) .scaleEffect(isAnimating ? 1.2 : 1.0) .opacity(isAnimating ? 1.0 : 0.0) - - Text("Sora") - .font(.largeTitle) - .fontWeight(.bold) - .opacity(isAnimating ? 1.0 : 0.0) } .onAppear { withAnimation(.easeIn(duration: 0.5)) { isAnimating = true } - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { withAnimation(.easeOut(duration: 0.5)) { showMainApp = true } From b5efb1ad1976b1951b2037721ab5d54f1b387424 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Sat, 14 Jun 2025 16:35:25 +0200 Subject: [PATCH 51/52] fixed multi API calls --- .../Trakt/Mutations/TraktPushUpdates.swift | 10 -- .../CustomPlayer/CustomPlayer.swift | 71 ++++++----- Sora/Utils/MediaPlayer/VideoPlayer.swift | 113 +++++++++++------- 3 files changed, 101 insertions(+), 93 deletions(-) diff --git a/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift b/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift index 4c2e0fa..5c2eb20 100644 --- a/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift +++ b/Sora/Tracking Services/Trakt/Mutations/TraktPushUpdates.swift @@ -33,28 +33,22 @@ class TraktMutation { func markAsWatched(type: String, tmdbID: Int, episodeNumber: Int? = nil, seasonNumber: Int? = nil, completion: @escaping (Result) -> Void) { let sendTraktUpdates = UserDefaults.standard.object(forKey: "sendTraktUpdates") as? Bool ?? true if !sendTraktUpdates { - Logger.shared.log("Trakt updates disabled by user preference", type: "Debug") completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Trakt updates disabled by user"]))) return } - Logger.shared.log("Attempting to mark \(type) as watched - TMDB ID: \(tmdbID), Episode: \(episodeNumber ?? 0), Season: \(seasonNumber ?? 0)", type: "Debug") - guard let userToken = getTokenFromKeychain() else { Logger.shared.log("Trakt access token not found in keychain", type: "Error") completion(.failure(NSError(domain: "", code: -1, userInfo: [NSLocalizedDescriptionKey: "Access token not found"]))) return } - Logger.shared.log("Found Trakt access token, proceeding with API call", type: "Debug") - let endpoint = "/sync/history" let watchedAt = ISO8601DateFormatter().string(from: Date()) let body: [String: Any] switch type { case "movie": - Logger.shared.log("Preparing movie watch request for TMDB ID: \(tmdbID)", type: "Debug") body = [ "movies": [ [ @@ -118,8 +112,6 @@ class TraktMutation { return } - Logger.shared.log("Sending Trakt API request to: \(request.url?.absoluteString ?? "unknown")", type: "Debug") - let task = URLSession.shared.dataTask(with: request) { data, response, error in if let error = error { Logger.shared.log("Trakt API network error: \(error.localizedDescription)", type: "Error") @@ -133,8 +125,6 @@ class TraktMutation { return } - Logger.shared.log("Trakt API Response Status: \(httpResponse.statusCode)", type: "Debug") - if let data = data, let responseString = String(data: data, encoding: .utf8) { Logger.shared.log("Trakt API Response Body: \(responseString)", type: "Debug") } diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index bf6b098..b48d5bf 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -34,6 +34,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private let aniListMaxRetries = 6 private let totalEpisodes: Int + private var traktUpdateSent = false + private var traktUpdatedSuccessfully = false + var player: AVPlayer! var timeObserverToken: Any? var inactivityTimer: Timer? @@ -1294,7 +1297,6 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele dimButtonToRight = dimButton.trailingAnchor.constraint(equalTo: controlsContainerView.trailingAnchor, constant: -16) dimButtonToSlider.isActive = true } - private func setupLockButton() { let cfg = UIImage.SymbolConfiguration(pointSize: 24, weight: .regular) lockButton = UIButton(type: .system) @@ -1647,42 +1649,8 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele self.tryAniListUpdate() } - if let tmdbId = self.tmdbID, tmdbId > 0 { - Logger.shared.log("Attempting Trakt update - TMDB ID: \(tmdbId), isMovie: \(self.isMovie), episode: \(self.episodeNumber), season: \(self.seasonNumber)", type: "Debug") - - let traktMutation = TraktMutation() - - if self.isMovie { - traktMutation.markAsWatched(type: "movie", tmdbID: tmdbId) { result in - switch result { - case .success: - Logger.shared.log("Successfully updated Trakt progress for movie (TMDB: \(tmdbId))", type: "General") - case .failure(let error): - Logger.shared.log("Failed to update Trakt progress for movie: \(error.localizedDescription)", type: "Error") - } - } - } else { - guard self.episodeNumber > 0 && self.seasonNumber > 0 else { - Logger.shared.log("Invalid episode (\(self.episodeNumber)) or season (\(self.seasonNumber)) number for Trakt update", type: "Error") - return - } - - traktMutation.markAsWatched( - type: "episode", - tmdbID: tmdbId, - episodeNumber: self.episodeNumber, - seasonNumber: self.seasonNumber - ) { result in - switch result { - case .success: - Logger.shared.log("Successfully updated Trakt progress for episode \(self.episodeNumber) (TMDB: \(tmdbId))", type: "General") - case .failure(let error): - Logger.shared.log("Failed to update Trakt progress for episode: \(error.localizedDescription)", type: "Error") - } - } - } - } else { - Logger.shared.log("Skipping Trakt update - TMDB ID not set or invalid: \(self.tmdbID ?? -1)", type: "Warning") + if let tmdbId = self.tmdbID, tmdbId > 0, !self.traktUpdateSent { + self.sendTraktUpdate(tmdbId: tmdbId) } } @@ -2874,6 +2842,35 @@ extension CustomMediaPlayerViewController: AVPictureInPictureControllerDelegate } } +// yes? Like the plural of the famous american rapper ye? -IBHRAD +// low taper fade the meme is massive -cranci +// The mind is the source of good and evil, only you yourself can decide which you will bring yourself. -seiike +// guys watch Clannad already - ibro +// May the Divine Providence bestow its infinite mercy upon your soul, and may eternal grace find you beyond the shadows of this mortal realm. - paul, 15/11/2005 - 13/05/2023 +// this dumbass ↑ defo used gpt, ong he did bro + let maskLayer = CAShapeLayer() + maskLayer.path = path.cgPath + maskLayer.fillColor = nil + maskLayer.strokeColor = UIColor.white.cgColor + maskLayer.lineWidth = 0.5 + gradientLayer.mask = maskLayer + } +} + +extension CustomMediaPlayerViewController: AVPictureInPictureControllerDelegate { + func pictureInPictureControllerWillStartPictureInPicture(_ pipController: AVPictureInPictureController) { + pipButton.alpha = 0.5 + } + + func pictureInPictureControllerDidStopPictureInPicture(_ pipController: AVPictureInPictureController) { + pipButton.alpha = 1.0 + } + + func pictureInPictureController(_ pipController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) { + Logger.shared.log("PiP failed to start: \(error.localizedDescription)", type: "Error") + } +} + // yes? Like the plural of the famous american rapper ye? -IBHRAD // low taper fade the meme is massive -cranci // The mind is the source of good and evil, only you yourself can decide which you will bring yourself. -seiike diff --git a/Sora/Utils/MediaPlayer/VideoPlayer.swift b/Sora/Utils/MediaPlayer/VideoPlayer.swift index 7d182de..3a12db0 100644 --- a/Sora/Utils/MediaPlayer/VideoPlayer.swift +++ b/Sora/Utils/MediaPlayer/VideoPlayer.swift @@ -29,6 +29,11 @@ class VideoPlayerViewController: UIViewController { var subtitlesLoader: VTTSubtitlesLoader? var subtitleLabel: UILabel? + private var aniListUpdateSent = false + private var aniListUpdatedSuccessfully = false + private var traktUpdateSent = false + private var traktUpdatedSuccessfully = false + init(module: ScrapingModule) { self.module = module super.init(nibName: nil, bundle: nil) @@ -203,54 +208,70 @@ class VideoPlayerViewController: UIViewController { let remainingPercentage = (duration - currentTime) / duration if remainingPercentage < 0.1 { - if self.aniListID != 0 { - let aniListMutation = AniListMutation() - aniListMutation.updateAnimeProgress(animeId: self.aniListID, episodeNumber: self.episodeNumber) { result in - switch result { - case .success: - Logger.shared.log("Updated AniList progress for Episode \(self.episodeNumber)", type: "General") - case .failure(let error): - Logger.shared.log("Could not update AniList progress: \(error.localizedDescription)", type: "Error") - } - } + if self.aniListID != 0 && !self.aniListUpdateSent { + self.sendAniListUpdate() } - if let tmdbId = self.tmdbID, tmdbId > 0 { - Logger.shared.log("Attempting Trakt update - TMDB ID: \(tmdbId), isMovie: \(self.isMovie), episode: \(self.episodeNumber), season: \(self.seasonNumber)", type: "Debug") - - let traktMutation = TraktMutation() - - if self.isMovie { - traktMutation.markAsWatched(type: "movie", tmdbID: tmdbId) { result in - switch result { - case .success: - Logger.shared.log("Updated Trakt progress for movie (TMDB: \(tmdbId))", type: "General") - case .failure(let error): - Logger.shared.log("Could not update Trakt progress for movie: \(error.localizedDescription)", type: "Error") - } - } - } else { - guard self.episodeNumber > 0 && self.seasonNumber > 0 else { - Logger.shared.log("Invalid episode (\(self.episodeNumber)) or season (\(self.seasonNumber)) number for Trakt update", type: "Error") - return - } - - traktMutation.markAsWatched( - type: "episode", - tmdbID: tmdbId, - episodeNumber: self.episodeNumber, - seasonNumber: self.seasonNumber - ) { result in - switch result { - case .success: - Logger.shared.log("Updated Trakt progress for Episode \(self.episodeNumber) (TMDB: \(tmdbId))", type: "General") - case .failure(let error): - Logger.shared.log("Could not update Trakt progress for episode: \(error.localizedDescription)", type: "Error") - } - } - } - } else { - Logger.shared.log("Skipping Trakt update - TMDB ID not set or invalid: \(self.tmdbID ?? -1)", type: "Warning") + if let tmdbId = self.tmdbID, tmdbId > 0, !self.traktUpdateSent { + self.sendTraktUpdate(tmdbId: tmdbId) + } + } + } + } + + private func sendAniListUpdate() { + guard !aniListUpdateSent else { return } + + aniListUpdateSent = true + let aniListMutation = AniListMutation() + + aniListMutation.updateAnimeProgress(animeId: self.aniListID, episodeNumber: self.episodeNumber) { [weak self] result in + switch result { + case .success: + self?.aniListUpdatedSuccessfully = true + Logger.shared.log("Successfully updated AniList progress for Episode \(self?.episodeNumber ?? 0)", type: "General") + case .failure(let error): + Logger.shared.log("Failed to update AniList progress: \(error.localizedDescription)", type: "Error") + } + } + } + + private func sendTraktUpdate(tmdbId: Int) { + guard !traktUpdateSent else { return } + + traktUpdateSent = true + Logger.shared.log("Attempting Trakt update - TMDB ID: \(tmdbId), isMovie: \(self.isMovie), episode: \(self.episodeNumber), season: \(self.seasonNumber)", type: "Debug") + + let traktMutation = TraktMutation() + + if self.isMovie { + traktMutation.markAsWatched(type: "movie", tmdbID: tmdbId) { [weak self] result in + switch result { + case .success: + self?.traktUpdatedSuccessfully = true + Logger.shared.log("Successfully updated Trakt progress for movie (TMDB: \(tmdbId))", type: "General") + case .failure(let error): + Logger.shared.log("Failed to update Trakt progress for movie: \(error.localizedDescription)", type: "Error") + } + } + } else { + guard self.episodeNumber > 0 && self.seasonNumber > 0 else { + Logger.shared.log("Invalid episode (\(self.episodeNumber)) or season (\(self.seasonNumber)) number for Trakt update", type: "Error") + return + } + + traktMutation.markAsWatched( + type: "episode", + tmdbID: tmdbId, + episodeNumber: self.episodeNumber, + seasonNumber: self.seasonNumber + ) { [weak self] result in + switch result { + case .success: + self?.traktUpdatedSuccessfully = true + Logger.shared.log("Successfully updated Trakt progress for Episode \(self?.episodeNumber ?? 0) (TMDB: \(tmdbId))", type: "General") + case .failure(let error): + Logger.shared.log("Failed to update Trakt progress for episode: \(error.localizedDescription)", type: "Error") } } } From 3d4c500a152cd8596fb58c9111340d4c900b99d6 Mon Sep 17 00:00:00 2001 From: cranci1 <100066266+cranci1@users.noreply.github.com> Date: Sat, 14 Jun 2025 16:40:27 +0200 Subject: [PATCH 52/52] ops --- .../CustomPlayer/CustomPlayer.swift | 72 +++++++++++-------- Sora/Utils/MediaPlayer/VideoPlayer.swift | 2 - 2 files changed, 41 insertions(+), 33 deletions(-) diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift index b48d5bf..b8dadba 100644 --- a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift +++ b/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift @@ -2065,6 +2065,45 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele } } + private func sendTraktUpdate(tmdbId: Int) { + guard !traktUpdateSent else { return } + traktUpdateSent = true + + let traktMutation = TraktMutation() + + if self.isMovie { + traktMutation.markAsWatched(type: "movie", tmdbID: tmdbId) { [weak self] result in + switch result { + case .success: + self?.traktUpdatedSuccessfully = true + Logger.shared.log("Successfully updated Trakt progress for movie (TMDB: \(tmdbId))", type: "General") + case .failure(let error): + Logger.shared.log("Failed to update Trakt progress for movie: \(error.localizedDescription)", type: "Error") + } + } + } else { + guard self.episodeNumber > 0 && self.seasonNumber > 0 else { + Logger.shared.log("Invalid episode (\(self.episodeNumber)) or season (\(self.seasonNumber)) number for Trakt update", type: "Error") + return + } + + traktMutation.markAsWatched( + type: "episode", + tmdbID: tmdbId, + episodeNumber: self.episodeNumber, + seasonNumber: self.seasonNumber + ) { [weak self] result in + switch result { + case .success: + self?.traktUpdatedSuccessfully = true + Logger.shared.log("Successfully updated Trakt progress for Episode \(self?.episodeNumber ?? 0) (TMDB: \(tmdbId))", type: "General") + case .failure(let error): + Logger.shared.log("Failed to update Trakt progress for episode: \(error.localizedDescription)", type: "Error") + } + } + } + } + private func animateButtonRotation(_ button: UIView, clockwise: Bool = true) { if button.layer.animation(forKey: "rotate360") != nil { return @@ -2189,9 +2228,9 @@ class CustomMediaPlayerViewController: UIViewController, UIGestureRecognizerDele private func switchToQuality(urlString: String) { guard let url = URL(string: urlString), - currentQualityURL?.absoluteString != urlString else { + currentQualityURL?.absoluteString != urlString else { Logger.shared.log("Quality Selection: Switch cancelled - same quality already selected", type: "General") - return + return } let qualityName = qualities.first(where: { $0.1 == urlString })?.0 ?? "Unknown" @@ -2842,35 +2881,6 @@ extension CustomMediaPlayerViewController: AVPictureInPictureControllerDelegate } } -// yes? Like the plural of the famous american rapper ye? -IBHRAD -// low taper fade the meme is massive -cranci -// The mind is the source of good and evil, only you yourself can decide which you will bring yourself. -seiike -// guys watch Clannad already - ibro -// May the Divine Providence bestow its infinite mercy upon your soul, and may eternal grace find you beyond the shadows of this mortal realm. - paul, 15/11/2005 - 13/05/2023 -// this dumbass ↑ defo used gpt, ong he did bro - let maskLayer = CAShapeLayer() - maskLayer.path = path.cgPath - maskLayer.fillColor = nil - maskLayer.strokeColor = UIColor.white.cgColor - maskLayer.lineWidth = 0.5 - gradientLayer.mask = maskLayer - } -} - -extension CustomMediaPlayerViewController: AVPictureInPictureControllerDelegate { - func pictureInPictureControllerWillStartPictureInPicture(_ pipController: AVPictureInPictureController) { - pipButton.alpha = 0.5 - } - - func pictureInPictureControllerDidStopPictureInPicture(_ pipController: AVPictureInPictureController) { - pipButton.alpha = 1.0 - } - - func pictureInPictureController(_ pipController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) { - Logger.shared.log("PiP failed to start: \(error.localizedDescription)", type: "Error") - } -} - // yes? Like the plural of the famous american rapper ye? -IBHRAD // low taper fade the meme is massive -cranci // The mind is the source of good and evil, only you yourself can decide which you will bring yourself. -seiike diff --git a/Sora/Utils/MediaPlayer/VideoPlayer.swift b/Sora/Utils/MediaPlayer/VideoPlayer.swift index 3a12db0..518c778 100644 --- a/Sora/Utils/MediaPlayer/VideoPlayer.swift +++ b/Sora/Utils/MediaPlayer/VideoPlayer.swift @@ -238,9 +238,7 @@ class VideoPlayerViewController: UIViewController { private func sendTraktUpdate(tmdbId: Int) { guard !traktUpdateSent else { return } - traktUpdateSent = true - Logger.shared.log("Attempting Trakt update - TMDB ID: \(tmdbId), isMovie: \(self.isMovie), episode: \(self.episodeNumber), season: \(self.seasonNumber)", type: "Debug") let traktMutation = TraktMutation()