From f1f993f76356b17b505ffbb271eb8ece7109ca2b Mon Sep 17 00:00:00 2001 From: cranci <100066266+cranci1@users.noreply.github.com> Date: Tue, 10 Jun 2025 17:19:47 +0200 Subject: [PATCH 1/2] toggle for subtitles (#166) (#167) --- .../Utils/DownloadUtils/DownloadManager.swift | 2 +- .../Downloads/JSController+MP4Download.swift | 265 +++++------------- .../Downloads/JSController-Downloads.swift | 138 +++++++-- .../JSController-StreamTypeDownload.swift | 3 +- .../CustomPlayer/CustomPlayer.swift | 258 +++++++++++------ .../Helpers/SubtitleSettingsManager.swift | 1 + Sora/Views/DownloadView.swift | 77 +++-- Sora/Views/MediaInfoView/MediaInfoView.swift | 24 +- .../SettingsSubViews/SettingsViewData.swift | 59 ++-- .../SettingsSubViews/SettingsViewPlayer.swift | 17 +- 10 files changed, 448 insertions(+), 396 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..fcf4bf2 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) @@ -282,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 { @@ -446,6 +472,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 +526,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 +2086,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 +2107,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 +2190,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/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/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 } } 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 522a591..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 @@ -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("Remove 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 @@ -214,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 @@ -286,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 { @@ -298,7 +283,7 @@ struct SettingsViewData: View { } } else { DispatchQueue.main.async { - self.cacheSizeText = "Unknown" + self.cacheSizeText = "N/A" self.isCalculatingSize = false } } 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 862286840f8374229d280260513fba1bf73c75e2 Mon Sep 17 00:00:00 2001 From: cranci <100066266+cranci1@users.noreply.github.com> Date: Sun, 15 Jun 2025 10:53:31 +0200 Subject: [PATCH 2/2] Public Testflight update? (#194) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fxied trackers text * Update SettingsViewTrackers.swift * Update SettingsViewGeneral.swift * Languages fix + Arabic + French + disable flip by Arabic (#193) * fixed tmp folder issues * 😭 * yeah * ok well lets test --------- Co-authored-by: 50/50 <80717571+50n50@users.noreply.github.com> --- .../ContinueWatchingItem.swift | 0 .../ContinueWatchingManager.swift | 0 .../Components/Double+Extension.swift | 0 .../Components/MusicProgressSlider.swift | 0 .../Components/VolumeSlider.swift | 0 .../CustomPlayer/CustomPlayer.swift | 0 .../Helpers/SubtitleSettingsManager.swift | 0 .../Helpers/VTTSubtitlesLoader.swift | 0 .../NormalPlayer}/NormalPlayer.swift | 0 .../NormalPlayer}/VideoPlayer.swift | 87 ++++ .../SharePlay/SharePlayCoordinator.swift | 78 ++++ .../SharePlay/SharePlayManager.swift | 77 ++++ .../SharePlay/VideoWatchingActivity.swift | 68 ++++ Sora/Sora.entitlements | 4 + Sora/SoraApp.swift | 20 +- .../Downloads/JSController+Downloader.swift | 1 - Sora/Views/DownloadView.swift | 1 + Sora/Views/MediaInfoView/MediaInfoView.swift | 6 - .../SettingsSubViews/SettingsViewData.swift | 99 +---- .../SettingsViewGeneral.swift | 5 +- .../SettingsViewTrackers.swift | 6 +- Sora/Views/SettingsView/SettingsView.swift | 14 +- Sora/ar.lproj/Localizable.strings | 384 ++++++++++++++++++ Sora/en.lproj/Localizable.strings | 384 ++++++++++++++++++ Sora/fr.lproj/Localizable.strings | 384 ++++++++++++++++++ Sora/nl.lproj/Localizable.strings | 384 ++++++++++++++++++ Sulfur.xcodeproj/project.pbxproj | 130 +++++- 27 files changed, 2010 insertions(+), 122 deletions(-) rename Sora/{Utils => MediaUtils}/ContinueWatching/ContinueWatchingItem.swift (100%) rename Sora/{Utils => MediaUtils}/ContinueWatching/ContinueWatchingManager.swift (100%) rename Sora/{Utils/MediaPlayer => MediaUtils}/CustomPlayer/Components/Double+Extension.swift (100%) rename Sora/{Utils/MediaPlayer => MediaUtils}/CustomPlayer/Components/MusicProgressSlider.swift (100%) rename Sora/{Utils/MediaPlayer => MediaUtils}/CustomPlayer/Components/VolumeSlider.swift (100%) rename Sora/{Utils/MediaPlayer => MediaUtils}/CustomPlayer/CustomPlayer.swift (100%) rename Sora/{Utils/MediaPlayer => MediaUtils}/CustomPlayer/Helpers/SubtitleSettingsManager.swift (100%) rename Sora/{Utils/MediaPlayer => MediaUtils}/CustomPlayer/Helpers/VTTSubtitlesLoader.swift (100%) rename Sora/{Utils/MediaPlayer => MediaUtils/NormalPlayer}/NormalPlayer.swift (100%) rename Sora/{Utils/MediaPlayer => MediaUtils/NormalPlayer}/VideoPlayer.swift (79%) create mode 100644 Sora/MediaUtils/SharePlay/SharePlayCoordinator.swift create mode 100644 Sora/MediaUtils/SharePlay/SharePlayManager.swift create mode 100644 Sora/MediaUtils/SharePlay/VideoWatchingActivity.swift create mode 100644 Sora/ar.lproj/Localizable.strings create mode 100644 Sora/en.lproj/Localizable.strings create mode 100644 Sora/fr.lproj/Localizable.strings create mode 100644 Sora/nl.lproj/Localizable.strings diff --git a/Sora/Utils/ContinueWatching/ContinueWatchingItem.swift b/Sora/MediaUtils/ContinueWatching/ContinueWatchingItem.swift similarity index 100% rename from Sora/Utils/ContinueWatching/ContinueWatchingItem.swift rename to Sora/MediaUtils/ContinueWatching/ContinueWatchingItem.swift diff --git a/Sora/Utils/ContinueWatching/ContinueWatchingManager.swift b/Sora/MediaUtils/ContinueWatching/ContinueWatchingManager.swift similarity index 100% rename from Sora/Utils/ContinueWatching/ContinueWatchingManager.swift rename to Sora/MediaUtils/ContinueWatching/ContinueWatchingManager.swift diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Components/Double+Extension.swift b/Sora/MediaUtils/CustomPlayer/Components/Double+Extension.swift similarity index 100% rename from Sora/Utils/MediaPlayer/CustomPlayer/Components/Double+Extension.swift rename to Sora/MediaUtils/CustomPlayer/Components/Double+Extension.swift diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift b/Sora/MediaUtils/CustomPlayer/Components/MusicProgressSlider.swift similarity index 100% rename from Sora/Utils/MediaPlayer/CustomPlayer/Components/MusicProgressSlider.swift rename to Sora/MediaUtils/CustomPlayer/Components/MusicProgressSlider.swift diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Components/VolumeSlider.swift b/Sora/MediaUtils/CustomPlayer/Components/VolumeSlider.swift similarity index 100% rename from Sora/Utils/MediaPlayer/CustomPlayer/Components/VolumeSlider.swift rename to Sora/MediaUtils/CustomPlayer/Components/VolumeSlider.swift diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift b/Sora/MediaUtils/CustomPlayer/CustomPlayer.swift similarity index 100% rename from Sora/Utils/MediaPlayer/CustomPlayer/CustomPlayer.swift rename to Sora/MediaUtils/CustomPlayer/CustomPlayer.swift diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/SubtitleSettingsManager.swift b/Sora/MediaUtils/CustomPlayer/Helpers/SubtitleSettingsManager.swift similarity index 100% rename from Sora/Utils/MediaPlayer/CustomPlayer/Helpers/SubtitleSettingsManager.swift rename to Sora/MediaUtils/CustomPlayer/Helpers/SubtitleSettingsManager.swift diff --git a/Sora/Utils/MediaPlayer/CustomPlayer/Helpers/VTTSubtitlesLoader.swift b/Sora/MediaUtils/CustomPlayer/Helpers/VTTSubtitlesLoader.swift similarity index 100% rename from Sora/Utils/MediaPlayer/CustomPlayer/Helpers/VTTSubtitlesLoader.swift rename to Sora/MediaUtils/CustomPlayer/Helpers/VTTSubtitlesLoader.swift diff --git a/Sora/Utils/MediaPlayer/NormalPlayer.swift b/Sora/MediaUtils/NormalPlayer/NormalPlayer.swift similarity index 100% rename from Sora/Utils/MediaPlayer/NormalPlayer.swift rename to Sora/MediaUtils/NormalPlayer/NormalPlayer.swift diff --git a/Sora/Utils/MediaPlayer/VideoPlayer.swift b/Sora/MediaUtils/NormalPlayer/VideoPlayer.swift similarity index 79% rename from Sora/Utils/MediaPlayer/VideoPlayer.swift rename to Sora/MediaUtils/NormalPlayer/VideoPlayer.swift index 518c778..c7e036c 100644 --- a/Sora/Utils/MediaPlayer/VideoPlayer.swift +++ b/Sora/MediaUtils/NormalPlayer/VideoPlayer.swift @@ -7,6 +7,8 @@ import UIKit import AVKit +import Combine +import GroupActivities class VideoPlayerViewController: UIViewController { let module: ScrapingModule @@ -29,6 +31,9 @@ class VideoPlayerViewController: UIViewController { var subtitlesLoader: VTTSubtitlesLoader? var subtitleLabel: UILabel? + private var sharePlayCoordinator: SharePlayCoordinator? + private var subscriptions = Set() + private var aniListUpdateSent = false private var aniListUpdatedSuccessfully = false private var traktUpdateSent = false @@ -40,6 +45,7 @@ class VideoPlayerViewController: UIViewController { if UserDefaults.standard.object(forKey: "subtitlesEnabled") == nil { UserDefaults.standard.set(true, forKey: "subtitlesEnabled") } + setupSharePlay() } required init?(coder: NSCoder) { @@ -129,6 +135,10 @@ class VideoPlayerViewController: UIViewController { if !subtitles.isEmpty && UserDefaults.standard.bool(forKey: "subtitlesEnabled") { setupSubtitles() } + + // Configure SharePlay after player setup + setupSharePlayButton(in: playerViewController) + configureSharePlayForPlayer() } addPeriodicTimeObserver(fullURL: fullUrl) @@ -275,6 +285,79 @@ class VideoPlayerViewController: UIViewController { } } + @MainActor + private func setupSharePlay() { + sharePlayCoordinator = SharePlayCoordinator() + sharePlayCoordinator?.configureGroupSession() + + if let playerViewController = playerViewController { + setupSharePlayButton(in: playerViewController) + } + } + + private func setupSharePlayButton(in playerViewController: NormalPlayer) { + // WIP + } + + @MainActor + private func startSharePlay() { + guard let streamUrl = streamUrl else { return } + + Task { + var episodeImageData: Data? + if !episodeImageUrl.isEmpty, let imageUrl = URL(string: episodeImageUrl) { + episodeImageData = try? await URLSession.shared.data(from: imageUrl).0 + } + + let activity = VideoWatchingActivity( + mediaTitle: mediaTitle, + episodeNumber: episodeNumber, + streamUrl: streamUrl, + subtitles: subtitles, + aniListID: aniListID, + fullUrl: fullUrl, + headers: headers, + episodeImageUrl: episodeImageUrl, + episodeImageData: episodeImageData, + totalEpisodes: totalEpisodes, + tmdbID: tmdbID, + isMovie: isMovie, + seasonNumber: seasonNumber + ) + + await sharePlayCoordinator?.startSharePlay(with: activity) + } + } + + private func configureSharePlayForPlayer() { + guard let player = player else { return } + sharePlayCoordinator?.coordinatePlayback(with: player) + } + + @MainActor + func presentSharePlayInvitation() { + guard let streamUrl = streamUrl else { + Logger.shared.log("Cannot start SharePlay: Stream URL is nil", type: "Error") + return + } + + SharePlayManager.shared.presentSharePlayInvitation( + from: self, + mediaTitle: mediaTitle, + episodeNumber: episodeNumber, + streamUrl: streamUrl, + subtitles: subtitles, + aniListID: aniListID, + fullUrl: fullUrl, + headers: headers, + episodeImageUrl: episodeImageUrl, + totalEpisodes: totalEpisodes, + tmdbID: tmdbID, + isMovie: isMovie, + seasonNumber: seasonNumber + ) + } + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { if UserDefaults.standard.bool(forKey: "alwaysLandscape") { return .landscape @@ -299,5 +382,9 @@ class VideoPlayerViewController: UIViewController { subtitleLabel?.removeFromSuperview() subtitleLabel = nil subtitlesLoader = nil + + sharePlayCoordinator?.leaveGroupSession() + sharePlayCoordinator = nil + subscriptions.removeAll() } } diff --git a/Sora/MediaUtils/SharePlay/SharePlayCoordinator.swift b/Sora/MediaUtils/SharePlay/SharePlayCoordinator.swift new file mode 100644 index 0000000..dea9d61 --- /dev/null +++ b/Sora/MediaUtils/SharePlay/SharePlayCoordinator.swift @@ -0,0 +1,78 @@ +// +// SharePlayCoordinator.swift +// Sora +// +// Created by Francesco on 15/06/25. +// + +import Combine +import Foundation +import AVFoundation +import GroupActivities + +@MainActor +class SharePlayCoordinator: ObservableObject { + private var subscriptions = Set() + private var groupSession: GroupSession? + + @Published var isEligibleForGroupSession = false + @Published var groupSessionState: GroupSession.State = .waiting + + private var playbackCoordinator: AVPlayerPlaybackCoordinator? + + func configureGroupSession() { + Task { + for await session in VideoWatchingActivity.sessions() { + await configureGroupSession(session) + } + } + } + + private func configureGroupSession(_ groupSession: GroupSession) async { + self.groupSession = groupSession + + groupSession.$state + .receive(on: DispatchQueue.main) + .assign(to: &$groupSessionState) + + groupSession.$activeParticipants + .receive(on: DispatchQueue.main) + .sink { participants in + Logger.shared.log("Active participants: \(participants.count)", type: "SharePlay") + } + .store(in: &subscriptions) + + groupSession.join() + } + + func startSharePlay(with activity: VideoWatchingActivity) async { + do { + _ = try await activity.activate() + Logger.shared.log("SharePlay activity activated successfully", type: "SharePlay") + } catch { + Logger.shared.log("Failed to activate SharePlay: \(error.localizedDescription)", type: "Error") + } + } + + func coordinatePlayback(with player: AVPlayer) { + guard let groupSession = groupSession else { return } + + playbackCoordinator = player.playbackCoordinator + playbackCoordinator?.coordinateWithSession(groupSession) + + Logger.shared.log("Playback coordination established", type: "SharePlay") + } + + nonisolated func leaveGroupSession() { + Task { @MainActor in + self.groupSession?.leave() + self.playbackCoordinator = nil + Logger.shared.log("Left SharePlay session", type: "SharePlay") + } + } + + deinit { + subscriptions.removeAll() + playbackCoordinator = nil + } +} diff --git a/Sora/MediaUtils/SharePlay/SharePlayManager.swift b/Sora/MediaUtils/SharePlay/SharePlayManager.swift new file mode 100644 index 0000000..415510c --- /dev/null +++ b/Sora/MediaUtils/SharePlay/SharePlayManager.swift @@ -0,0 +1,77 @@ +// +// SharePlayManager.swift +// Sora +// +// Created by Francesco on 15/06/25. +// + +import UIKit +import Foundation +import GroupActivities + +class SharePlayManager { + static let shared = SharePlayManager() + + private init() {} + + func isSharePlayAvailable() -> Bool { + return true + } + + func presentSharePlayInvitation(from viewController: UIViewController, + mediaTitle: String, + episodeNumber: Int, + streamUrl: String, + subtitles: String = "", + aniListID: Int = 0, + fullUrl: String, + headers: [String: String]? = nil, + episodeImageUrl: String = "", + totalEpisodes: Int = 0, + tmdbID: Int? = nil, + isMovie: Bool = false, + seasonNumber: Int = 1) { + + Task { @MainActor in + var episodeImageData: Data? + if !episodeImageUrl.isEmpty, let imageUrl = URL(string: episodeImageUrl) { + do { + episodeImageData = try await URLSession.shared.data(from: imageUrl).0 + } catch { + Logger.shared.log("Failed to load episode image for SharePlay: \(error.localizedDescription)", type: "Error") + } + } + + let activity = VideoWatchingActivity( + mediaTitle: mediaTitle, + episodeNumber: episodeNumber, + streamUrl: streamUrl, + subtitles: subtitles, + aniListID: aniListID, + fullUrl: fullUrl, + headers: headers, + episodeImageUrl: episodeImageUrl, + episodeImageData: episodeImageData, + totalEpisodes: totalEpisodes, + tmdbID: tmdbID, + isMovie: isMovie, + seasonNumber: seasonNumber + ) + + do { + _ = try await activity.activate() + Logger.shared.log("SharePlay invitation sent successfully", type: "SharePlay") + } catch { + Logger.shared.log("Failed to send SharePlay invitation: \(error.localizedDescription)", type: "Error") + + let alert = UIAlertController( + title: "SharePlay Unavailable", + message: "SharePlay is not available right now. Make sure you're connected to FaceTime or have SharePlay enabled in Control Center.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + viewController.present(alert, animated: true) + } + } + } +} diff --git a/Sora/MediaUtils/SharePlay/VideoWatchingActivity.swift b/Sora/MediaUtils/SharePlay/VideoWatchingActivity.swift new file mode 100644 index 0000000..f055604 --- /dev/null +++ b/Sora/MediaUtils/SharePlay/VideoWatchingActivity.swift @@ -0,0 +1,68 @@ +// +// VideoWatchingActivity.swift +// Sora +// +// Created by Francesco on 15/06/25. +// + +import UIKit +import Foundation +import GroupActivities + +struct VideoWatchingActivity: GroupActivity { + var metadata: GroupActivityMetadata { + var metadata = GroupActivityMetadata() + metadata.title = mediaTitle + metadata.subtitle = "Episode \(episodeNumber)" + + if let imageData = episodeImageData, + let uiImage = UIImage(data: imageData) { + metadata.previewImage = uiImage.cgImage + } + + metadata.type = .watchTogether + return metadata + } + + let mediaTitle: String + let episodeNumber: Int + let streamUrl: String + let subtitles: String + let aniListID: Int + let fullUrl: String + let headers: [String: String]? + let episodeImageUrl: String + let episodeImageData: Data? + let totalEpisodes: Int + let tmdbID: Int? + let isMovie: Bool + let seasonNumber: Int + + init(mediaTitle: String, + episodeNumber: Int, + streamUrl: String, + subtitles: String = "", + aniListID: Int = 0, + fullUrl: String, + headers: [String: String]? = nil, + episodeImageUrl: String = "", + episodeImageData: Data? = nil, + totalEpisodes: Int = 0, + tmdbID: Int? = nil, + isMovie: Bool = false, + seasonNumber: Int = 1) { + self.mediaTitle = mediaTitle + self.episodeNumber = episodeNumber + self.streamUrl = streamUrl + self.subtitles = subtitles + self.aniListID = aniListID + self.fullUrl = fullUrl + self.headers = headers + self.episodeImageUrl = episodeImageUrl + self.episodeImageData = episodeImageData + self.totalEpisodes = totalEpisodes + self.tmdbID = tmdbID + self.isMovie = isMovie + self.seasonNumber = seasonNumber + } +} diff --git a/Sora/Sora.entitlements b/Sora/Sora.entitlements index b14b07a..4c847eb 100644 --- a/Sora/Sora.entitlements +++ b/Sora/Sora.entitlements @@ -2,6 +2,10 @@ + com.apple.developer.group-session + + com.apple.developer.group-session.video + com.apple.security.app-sandbox com.apple.security.assets.movies.read-write diff --git a/Sora/SoraApp.swift b/Sora/SoraApp.swift index 560ba24..5b33256 100644 --- a/Sora/SoraApp.swift +++ b/Sora/SoraApp.swift @@ -19,6 +19,7 @@ struct SoraApp: App { if let userAccentColor = UserDefaults.standard.color(forKey: "accentColor") { UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = userAccentColor } + clearTmpFolder() TraktToken.checkAuthenticationStatus { isAuthenticated in if isAuthenticated { @@ -38,6 +39,7 @@ struct SoraApp: App { ContentView() } } + .environment(\.layoutDirection, .leftToRight) .environmentObject(moduleManager) .environmentObject(settings) .environmentObject(libraryManager) @@ -102,4 +104,20 @@ struct SoraApp: App { break } } -} \ No newline at end of file + + private func clearTmpFolder() { + let fileManager = FileManager.default + let tmpDirectory = NSTemporaryDirectory() + + do { + let tmpURL = URL(fileURLWithPath: tmpDirectory) + let tmpContents = try fileManager.contentsOfDirectory(at: tmpURL, includingPropertiesForKeys: nil) + + for url in tmpContents { + try fileManager.removeItem(at: url) + } + } catch { + Logger.shared.log("Failed to clear tmp folder: \(error.localizedDescription)", type: "Error") + } + } +} diff --git a/Sora/Utils/JSLoader/Downloads/JSController+Downloader.swift b/Sora/Utils/JSLoader/Downloads/JSController+Downloader.swift index 621152c..9ebe998 100644 --- a/Sora/Utils/JSLoader/Downloads/JSController+Downloader.swift +++ b/Sora/Utils/JSLoader/Downloads/JSController+Downloader.swift @@ -9,7 +9,6 @@ import Foundation import SwiftUI import AVFoundation - struct DownloadRequest { let url: URL let headers: [String: String] diff --git a/Sora/Views/DownloadView.swift b/Sora/Views/DownloadView.swift index 8093104..6233474 100644 --- a/Sora/Views/DownloadView.swift +++ b/Sora/Views/DownloadView.swift @@ -1106,6 +1106,7 @@ struct EnhancedShowEpisodesView: View { } } .padding(.vertical) + .scrollViewBottomPadding() } .navigationTitle(NSLocalizedString("Episodes", comment: "")) .navigationBarTitleDisplayMode(.inline) diff --git a/Sora/Views/MediaInfoView/MediaInfoView.swift b/Sora/Views/MediaInfoView/MediaInfoView.swift index 66155ba..f2920de 100644 --- a/Sora/Views/MediaInfoView/MediaInfoView.swift +++ b/Sora/Views/MediaInfoView/MediaInfoView.swift @@ -1280,18 +1280,12 @@ struct MediaInfoView: View { 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") } checkCompletion() } } - - fetchAniListIDForSync() } private func fetchItemID(byTitle title: String, completion: @escaping (Result) -> Void) { diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift index 15e3b6e..a6f67ad 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewData.swift @@ -141,10 +141,9 @@ struct SettingsViewData: View { @State private var isCalculatingSize: Bool = false @State private var cacheSize: Int64 = 0 @State private var documentsSize: Int64 = 0 - @State private var downloadsSize: Int64 = 0 enum ActiveAlert { - case eraseData, removeDocs, removeDownloads, clearCache + case eraseData, removeDocs, clearCache } @State private var activeAlert: ActiveAlert = .eraseData @@ -154,7 +153,7 @@ struct SettingsViewData: View { VStack(spacing: 24) { SettingsSection( title: NSLocalizedString("App Storage", comment: ""), - footer: NSLocalizedString("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.", comment: "") + footer: NSLocalizedString("The app cache helps the app load images faster.\n\nClearing the Documents folder will delete all downloaded modules.\n\nErasing the App Data will clears all your settings and data of the app.", comment: "") ) { VStack(spacing: 0) { SettingsButtonRow( @@ -169,18 +168,6 @@ struct SettingsViewData: View { Divider().padding(.horizontal, 16) - SettingsButtonRow( - icon: "film", - title: NSLocalizedString("Remove Downloads", comment: ""), - subtitle: formatSize(downloadsSize), - action: { - activeAlert = .removeDownloads - showAlert = true - } - ) - - Divider().padding(.horizontal, 16) - SettingsButtonRow( icon: "doc.text", title: NSLocalizedString("Remove All Documents", comment: ""), @@ -209,7 +196,6 @@ struct SettingsViewData: View { .onAppear { calculateCacheSize() updateSizes() - calculateDownloadsSize() } .alert(isPresented: $showAlert) { switch activeAlert { @@ -231,15 +217,6 @@ struct SettingsViewData: View { }, secondaryButton: .cancel() ) - case .removeDownloads: - return Alert( - title: Text(NSLocalizedString("Remove Downloaded Media", comment: "")), - message: Text(NSLocalizedString("Are you sure you want to remove all downloaded media files (.mov, .mp4, .pkg)? This action cannot be undone.", comment: "")), - primaryButton: .destructive(Text(NSLocalizedString("Remove", comment: ""))) { - removeDownloadedMedia() - }, - secondaryButton: .cancel() - ) case .clearCache: return Alert( title: Text(NSLocalizedString("Clear Cache", comment: "")), @@ -286,42 +263,6 @@ struct SettingsViewData: View { } } - 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() } @@ -337,47 +278,12 @@ struct SettingsViewData: View { 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 { @@ -431,4 +337,3 @@ struct SettingsViewData: View { return formatter.string(fromByteCount: bytes) } } - diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift index 1c48ac8..ee0ebc9 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewGeneral.swift @@ -202,9 +202,10 @@ struct SettingsViewGeneral: View { SettingsPickerRow( icon: "globe", title: NSLocalizedString("App Language", comment: ""), - options: ["English", "Dutch"], + options: ["English", "Dutch", "French", "Arabic"], optionToString: { $0 }, - selection: $settings.selectedLanguage + selection: $settings.selectedLanguage, + showDivider: false ) .onChange(of: settings.selectedLanguage) { _ in showRestartAlert = true diff --git a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift index 2d4ac59..0ff5bab 100644 --- a/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift +++ b/Sora/Views/SettingsView/SettingsSubViews/SettingsViewTrackers.swift @@ -147,7 +147,7 @@ struct SettingsViewTrackers: View { .frame(height: 18) } else if isAnilistLoggedIn { HStack(spacing: 0) { - Text(NSLocalizedString("Logged in as", comment: "")) + Text(NSLocalizedString("Logged in as ", comment: "")) .font(.footnote) .foregroundStyle(.gray) Text(anilistUsername) @@ -240,7 +240,7 @@ struct SettingsViewTrackers: View { .frame(height: 18) } else if isTraktLoggedIn { HStack(spacing: 0) { - Text(NSLocalizedString("Logged in as", comment: "")) + Text(NSLocalizedString("Logged in as ", comment: "")) .font(.footnote) .foregroundStyle(.gray) Text(traktUsername) @@ -270,7 +270,7 @@ struct SettingsViewTrackers: View { SettingsToggleRow( icon: "arrow.triangle.2.circlepath", - title: NSLocalizedString("Sync TV shows progress", comment: ""), + title: NSLocalizedString("Sync shows/movies progress", comment: ""), isOn: $isSendTraktUpdates, showDivider: false ) diff --git a/Sora/Views/SettingsView/SettingsView.swift b/Sora/Views/SettingsView/SettingsView.swift index 51f191c..722ebef 100644 --- a/Sora/Views/SettingsView/SettingsView.swift +++ b/Sora/Views/SettingsView/SettingsView.swift @@ -174,7 +174,7 @@ struct SettingsView: View { Divider().padding(.horizontal, 16) NavigationLink(destination: SettingsViewDownloads()) { - SettingsNavigationRow(icon: "arrow.down.circle", titleKey: "Download") + SettingsNavigationRow(icon: "arrow.down.circle", titleKey: "Downloads") } Divider().padding(.horizontal, 16) @@ -400,7 +400,17 @@ class Settings: ObservableObject { } func updateLanguage() { - let languageCode = selectedLanguage == "Dutch" ? "nl" : "en" + let languageCode: String + switch selectedLanguage { + case "Dutch": + languageCode = "nl" + case "French": + languageCode = "fr" + case "Arabic": + languageCode = "ar" + default: + languageCode = "en" + } UserDefaults.standard.set([languageCode], forKey: "AppleLanguages") UserDefaults.standard.synchronize() } diff --git a/Sora/ar.lproj/Localizable.strings b/Sora/ar.lproj/Localizable.strings new file mode 100644 index 0000000..3235d42 --- /dev/null +++ b/Sora/ar.lproj/Localizable.strings @@ -0,0 +1,384 @@ +/* General */ +"About" = "Ű­ÙˆÙ„"; +"About Sora" = "Ű­ÙˆÙ„ Sora"; +"Active" = "Ù†ŰŽŰ·"; +"Active Downloads" = "Ű§Ù„ŰȘنŰČÙŠÙ„Ű§ŰȘ Ű§Ù„Ù†ŰŽŰ·Ű©"; +"Actively downloading media can be tracked from here." = "يمكن ŰȘŰȘŰšŰč Ű§Ù„Ù…Ű­ŰȘوى Ű§Ù„Ű°ÙŠ يŰȘم ŰȘنŰČيله Ű­Ű§Ù„ÙŠÙ‹Ű§ من Ù‡Ù†Ű§."; +"Add Module" = "Ű„Ű¶Ű§ÙŰ© ÙˆŰ­ŰŻŰ©"; +"Adjust the number of media items per row in portrait and landscape modes." = "ۧ۶ۚ۷ ŰčŰŻŰŻ ŰčÙ†Ű§Ű”Ű± Ű§Ù„Ù…Ű­ŰȘوى في كل Ű”Ù في Ű§Ù„ÙˆŰ¶Űčين Ű§Ù„Ű±ŰŁŰłÙŠ ÙˆŰ§Ù„ŰŁÙÙ‚ÙŠ."; +"Advanced" = "مŰȘÙ‚ŰŻÙ…"; +"AKA Sulfur" = "يŰčŰ±Ù ŰŁÙŠŰ¶Ù‹Ű§ ŰšŰ§ŰłÙ… Sulfur"; +"All Bookmarks" = "Ű§Ù„ŰčÙ†Ű§Ű”Ű± Ű§Ù„Ù…Ű­ÙÙˆŰžŰ©"; +"All Watching" = "Ű§Ù„ÙƒÙ„ Ù‚ÙŠŰŻ Ű§Ù„Ù…ŰŽŰ§Ù‡ŰŻŰ©"; +"Also known as Sulfur" = "يŰčŰ±Ù ŰŁÙŠŰ¶Ù‹Ű§ ŰšŰ§ŰłÙ… Sulfur"; +"AniList" = "AniList"; +"AniList ID" = "مŰčŰ±Ù‘Ù AniList"; +"AniList Match" = "Ù…Ű·Ű§ŰšÙ‚Ű© AniList"; +"AniList.co" = "AniList.co"; +"Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time." = "يŰȘم ŰŹÙ…Űč ŰšÙŠŰ§Ù†Ű§ŰȘ Ù…ŰŹÙ‡ÙˆÙ„Ű© Ű§Ù„Ù…Ű”ŰŻŰ± لŰȘŰ­ŰłÙŠÙ† Ű§Ù„ŰȘŰ·ŰšÙŠÙ‚. Ù„Ű§ يŰȘم ŰŹÙ…Űč ŰŁÙŠ مŰčÙ„ÙˆÙ…Ű§ŰȘ ŰŽŰźŰ”ÙŠŰ©. يمكن ŰȘŰčŰ·ÙŠÙ„ Ù‡Ű°Ű§ في ŰŁÙŠ وقŰȘ."; +"App Info" = "مŰčÙ„ÙˆÙ…Ű§ŰȘ Ű§Ù„ŰȘŰ·ŰšÙŠÙ‚"; +"App Language" = "لŰșŰ© Ű§Ù„ŰȘŰ·ŰšÙŠÙ‚"; +"App Storage" = "ŰȘŰźŰČين Ű§Ù„ŰȘŰ·ŰšÙŠÙ‚"; +"Appearance" = "Ű§Ù„Ù…ŰžÙ‡Ű±"; + +/* Alerts and Actions */ +"Are you sure you want to clear all cached data? This will help free up storage space." = "هل ŰŁÙ†ŰȘ مŰȘŰŁÙƒŰŻ من ŰŁÙ†Ùƒ ŰȘŰ±ÙŠŰŻ Ù…ŰłŰ­ ŰŹÙ…ÙŠŰč ŰšÙŠŰ§Ù†Ű§ŰȘ Ű°Ű§ÙƒŰ±Ű© Ű§Ù„ŰȘŰźŰČين Ű§Ù„Ù…Ű€Ù‚ŰȘ۟ ŰłÙŠŰłŰ§ŰčŰŻ Ù‡Ű°Ű§ في ŰȘŰ­Ű±ÙŠŰ± Ù…ŰłŰ§Ű­Ű© Ű§Ù„ŰȘŰźŰČين."; +"Are you sure you want to delete '%@'?" = "هل ŰŁÙ†ŰȘ مŰȘŰŁÙƒŰŻ ŰŁÙ†Ùƒ ŰȘŰ±ÙŠŰŻ Ű­Ű°Ù '%@'۟"; +"Are you sure you want to delete all %1$d episodes in '%2$@'?" = "هل ŰŁÙ†ŰȘ مŰȘŰŁÙƒŰŻ ŰŁÙ†Ùƒ ŰȘŰ±ÙŠŰŻ Ű­Ű°Ù ŰŹÙ…ÙŠŰč Ű§Ù„Ű­Ù„Ù‚Ű§ŰȘ Ű§Ù„ŰšŰ§Ù„Űș ŰčŰŻŰŻÙ‡Ű§ %1$d في '%2$@'۟"; +"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." = "هل ŰŁÙ†ŰȘ مŰȘŰŁÙƒŰŻ من ŰŁÙ†Ùƒ ŰȘŰ±ÙŠŰŻ Ű­Ű°Ù ŰŹÙ…ÙŠŰč Ű§Ù„ŰŁŰ”ÙˆÙ„ Ű§Ù„ŰȘي ŰȘم ŰȘنŰČÙŠÙ„Ù‡Ű§ŰŸ يمكنك ۧ۟ŰȘÙŠŰ§Ű± Ù…ŰłŰ­ Ű§Ù„Ù…ÙƒŰȘۚ۩ ÙÙ‚Ű· مŰč Ű§Ù„Ű§Ű­ŰȘÙŰ§Űž ŰšŰ§Ù„Ù…Ù„ÙŰ§ŰȘ Ű§Ù„ŰȘي ŰȘم ŰȘنŰČÙŠÙ„Ù‡Ű§ Ù„Ù„Ű§ŰłŰȘŰźŰŻŰ§Ù… في Ű§Ù„Ù…ŰłŰȘÙ‚ŰšÙ„."; +"Are you sure you want to erase all app data? This action cannot be undone." = "هل ŰŁÙ†ŰȘ مŰȘŰŁÙƒŰŻ ŰŁÙ†Ùƒ ŰȘŰ±ÙŠŰŻ Ù…Ű­Ùˆ ŰŹÙ…ÙŠŰč ŰšÙŠŰ§Ù†Ű§ŰȘ Ű§Ù„ŰȘŰ·ŰšÙŠÙ‚ŰŸ Ù‡Ű°Ű§ Ű§Ù„Ű„ŰŹŰ±Ű§ŰĄ Ù„Ű§ يمكن Ű§Ù„ŰȘ۱ۧۏŰč Űčنه."; + +/* Features */ +"Background Enabled" = "Ű§Ù„ŰȘفŰčيل في Ű§Ù„ŰźÙ„ÙÙŠŰ©"; +"Bookmark items for an easier access later." = "Ű§Ű­ÙŰž Ű§Ù„ŰčÙ†Ű§Ű”Ű± Ù„Ù„ÙˆŰ”ÙˆÙ„ Ű„Ù„ÙŠÙ‡Ű§ ŰšŰłÙ‡ÙˆÙ„Ű© Ù„Ű§Ű­Ù‚Ù‹Ű§."; +"Bookmarks" = "Ű§Ù„ŰčÙ†Ű§Ű”Ű± Ű§Ù„Ù…Ű­ÙÙˆŰžŰ©"; +"Bottom Padding" = "Ű§Ù„Ű­ŰŽÙˆŰ© Ű§Ù„ŰłÙÙ„ÙŠŰ©"; +"Cancel" = "Ű„Ù„Űșۧۥ"; +"Cellular Quality" = "ŰŹÙˆŰŻŰ© ŰšÙŠŰ§Ù†Ű§ŰȘ Ű§Ù„ŰŹÙˆŰ§Ù„"; +"Check out some community modules here!" = "Ű§Ű·Ù„Űč Űčلى ŰšŰč۶ ÙˆŰ­ŰŻŰ§ŰȘ Ű§Ù„Ù…ŰŹŰȘمŰč Ù‡Ù†Ű§!"; +"Choose preferred video resolution for WiFi and cellular connections. Higher resolutions use more data but provide better quality." = "ۧ۟ŰȘ۱ ŰŻÙ‚Ű© Ű§Ù„ÙÙŠŰŻÙŠÙˆ Ű§Ù„Ù…ÙŰ¶Ù„Ű© Ù„Ű§ŰȘŰ”Ű§Ù„Ű§ŰȘ WiFi ÙˆŰšÙŠŰ§Ù†Ű§ŰȘ Ű§Ù„ŰŹÙˆŰ§Ù„. Ű§Ù„ŰŻÙ‚Ű© Ű§Ù„ŰŁŰčلى ŰȘŰłŰȘهلك Ű§Ù„Ù…ŰČÙŠŰŻ من Ű§Ù„ŰšÙŠŰ§Ù†Ű§ŰȘ ÙˆÙ„ÙƒÙ†Ù‡Ű§ ŰȘÙˆÙŰ± ŰŹÙˆŰŻŰ© ŰŁÙŰ¶Ù„."; +"Clear" = "Ù…ŰłŰ­"; +"Clear All Downloads" = "Ù…ŰłŰ­ كل Ű§Ù„ŰȘنŰČÙŠÙ„Ű§ŰȘ"; +"Clear Cache" = "Ù…ŰłŰ­ Ű°Ű§ÙƒŰ±Ű© Ű§Ù„ŰȘŰźŰČين Ű§Ù„Ù…Ű€Ù‚ŰȘ"; +"Clear Library Only" = "Ù…ŰłŰ­ Ű§Ù„Ù…ÙƒŰȘۚ۩ ÙÙ‚Ű·"; +"Clear Logs" = "Ù…ŰłŰ­ Ű§Ù„ŰłŰŹÙ„Ű§ŰȘ"; +"Click the plus button to add a module!" = "Ű§Ù†Ù‚Ű± Űčلى ŰČ۱ Ű§Ù„Ű„Ű¶Ű§ÙŰ© Ù„Ű„Ű¶Ű§ÙŰ© ÙˆŰ­ŰŻŰ©!"; +"Continue Watching" = "مŰȘۧۚŰčŰ© Ű§Ù„Ù…ŰŽŰ§Ù‡ŰŻŰ©"; +"Continue Watching Episode %d" = "مŰȘۧۚŰčŰ© Ù…ŰŽŰ§Ù‡ŰŻŰ© Ű§Ù„Ű­Ù„Ù‚Ű© %d"; +"Contributors" = "Ű§Ù„Ù…ŰłŰ§Ù‡Ù…ÙˆÙ†"; +"Copied to Clipboard" = "ŰȘم Ű§Ù„Ù†ŰłŰź Ű„Ù„Ù‰ Ű§Ù„Ű­Ű§ÙŰžŰ©"; +"Copy to Clipboard" = "Ù†ŰłŰź Ű„Ù„Ù‰ Ű§Ù„Ű­Ű§ÙŰžŰ©"; +"Copy URL" = "Ù†ŰłŰź Ű§Ù„Ű±Ű§ŰšŰ·"; + +/* Episodes */ +"%lld Episodes" = "%lld Ű­Ù„Ù‚Ű§ŰȘ"; +"%lld of %lld" = "%1$lld من %2$lld"; +"%lld-%lld" = "%1$lld-%2$lld"; +"%lld%% seen" = "ŰȘمŰȘ Ù…ŰŽŰ§Ù‡ŰŻŰ© %lld%%"; +"Episode %lld" = "Ű§Ù„Ű­Ù„Ù‚Ű© %lld"; +"Episodes" = "Ű§Ù„Ű­Ù„Ù‚Ű§ŰȘ"; +"Episodes might not be available yet or there could be an issue with the source." = "Ù‚ŰŻ Ù„Ű§ ŰȘكون Ű§Ù„Ű­Ù„Ù‚Ű§ŰȘ مŰȘۭۧ۩ ŰšŰčŰŻ ŰŁÙˆ Ù‚ŰŻ ŰȘكون Ù‡Ù†Ű§Ùƒ Ù…ŰŽÙƒÙ„Ű© في Ű§Ù„Ù…Ű”ŰŻŰ±."; +"Episodes Range" = "Ù†Ű·Ű§Ù‚ Ű§Ù„Ű­Ù„Ù‚Ű§ŰȘ"; + +/* System */ +"cranci1" = "cranci1"; +"Dark" = "ŰŻŰ§ÙƒÙ†"; +"DATA & LOGS" = "Ű§Ù„ŰšÙŠŰ§Ù†Ű§ŰȘ ÙˆŰ§Ù„ŰłŰŹÙ„Ű§ŰȘ"; +"Debug" = "ŰȘŰ”Ű­ÙŠŰ­ Ű§Ù„ŰŁŰźŰ·Ű§ŰĄ"; +"Debugging and troubleshooting." = "ŰȘŰ”Ű­ÙŠŰ­ Ű§Ù„ŰŁŰźŰ·Ű§ŰĄ ÙˆŰ„Ű”Ù„Ű§Ű­Ù‡Ű§."; + +/* Actions */ +"Delete" = "Ű­Ű°Ù"; +"Delete All" = "Ű­Ű°Ù Ű§Ù„ÙƒÙ„"; +"Delete All Downloads" = "Ű­Ű°Ù كل Ű§Ù„ŰȘنŰČÙŠÙ„Ű§ŰȘ"; +"Delete All Episodes" = "Ű­Ű°Ù كل Ű§Ù„Ű­Ù„Ù‚Ű§ŰȘ"; +"Delete Download" = "Ű­Ű°Ù Ű§Ù„ŰȘنŰČيل"; +"Delete Episode" = "Ű­Ű°Ù Ű§Ù„Ű­Ù„Ù‚Ű©"; + +/* Player */ +"Double Tap to Seek" = "Ű§Ù†Ù‚Ű± Ù†Ù‚Ű±Ù‹Ű§ مŰČŰŻÙˆŰŹÙ‹Ű§ للŰȘÙ‚ŰŻÙŠÙ…"; +"Double tapping the screen on it's sides will skip with the short tap setting." = "Ű§Ù„Ù†Ù‚Ű± Ű§Ù„Ù…ŰČŰŻÙˆŰŹ Űčلى ŰŹŰ§Ù†ŰšÙŠ Ű§Ù„ŰŽŰ§ŰŽŰ© ŰłÙŠÙ‚ÙˆÙ… ŰšŰ§Ù„ŰȘÙ‚ŰŻÙŠÙ… ۭ۳ۚ Ű„Űčۯۧۯ Ű§Ù„Ù†Ù‚Ű± Ű§Ù„Ù‚Ű”ÙŠŰ±."; + +/* Downloads */ +"Download" = "ŰȘنŰČيل"; +"Download Episode" = "ŰȘنŰČيل Ű§Ù„Ű­Ù„Ù‚Ű©"; +"Download Summary" = "Ù…Ù„ŰźŰ” Ű§Ù„ŰȘنŰČيل"; +"Download This Episode" = "ŰȘنŰČيل Ù‡Ű°Ù‡ Ű§Ù„Ű­Ù„Ù‚Ű©"; +"Downloaded" = "ŰȘم Ű§Ù„ŰȘنŰČيل"; +"Downloaded Shows" = "Ű§Ù„ŰčŰ±ÙˆŰ¶ Ű§Ù„Ù…Ù†ŰČÙ‘Ù„Ű©"; +"Downloading" = "ŰŹŰ§Ű±ÙŠ Ű§Ù„ŰȘنŰČيل"; +"Downloads" = "Ű§Ù„ŰȘنŰČÙŠÙ„Ű§ŰȘ"; + +/* Settings */ +"Enable Analytics" = "ŰȘفŰčيل Ű§Ù„ŰȘŰ­Ù„ÙŠÙ„Ű§ŰȘ"; +"Enable Subtitles" = "ŰȘفŰčيل Ű§Ù„ŰȘŰ±ŰŹÙ…Ű©"; + +/* Data Management */ +"Erase" = "Ù…Ű­Ùˆ"; +"Erase all App Data" = "Ù…Ű­Ùˆ كل ŰšÙŠŰ§Ù†Ű§ŰȘ Ű§Ù„ŰȘŰ·ŰšÙŠÙ‚"; +"Erase App Data" = "Ù…Ű­Ùˆ ŰšÙŠŰ§Ù†Ű§ŰȘ Ű§Ù„ŰȘŰ·ŰšÙŠÙ‚"; + +/* Errors */ +"Error" = "۟۷ۣ"; +"Error Fetching Results" = "۟۷ۣ في ŰŹÙ„Űš Ű§Ù„Ù†ŰȘۧۊۏ"; +"Errors and critical issues." = "Ű§Ù„ŰŁŰźŰ·Ű§ŰĄ ÙˆŰ§Ù„Ù…ŰŽŰ§ÙƒÙ„ Ű§Ù„Ű­Ű±ŰŹŰ©."; +"Failed to load contributors" = "ÙŰŽÙ„ ŰȘŰ­Ù…ÙŠÙ„ Ű§Ù„Ù…ŰłŰ§Ù‡Ù…ÙŠÙ†"; + +/* Features */ +"Fetch Episode metadata" = "ŰŹÙ„Űš ŰšÙŠŰ§Ù†Ű§ŰȘ Ű§Ù„Ű­Ù„Ù‚Ű© Ű§Ù„ÙˆŰ”ÙÙŠŰ©"; +"Files Downloaded" = "Ű§Ù„Ù…Ù„ÙŰ§ŰȘ Ű§Ù„Ù…Ù†ŰČÙ‘Ù„Ű©"; +"Font Size" = "Ű­ŰŹÙ… Ű§Ù„ŰźŰ·"; + +/* Interface */ +"Force Landscape" = "ÙŰ±Ű¶ Ű§Ù„ÙˆŰ¶Űč Ű§Ù„ŰŁÙÙ‚ÙŠ"; +"General" = "ŰčŰ§Ù…"; +"General events and activities." = "Ű§Ù„ŰŁŰ­ŰŻŰ§Ű« ÙˆŰ§Ù„ŰŁÙ†ŰŽŰ·Ű© Ű§Ù„ŰčŰ§Ù…Ű©."; +"General Preferences" = "Ű§Ù„ŰȘÙŰ¶ÙŠÙ„Ű§ŰȘ Ű§Ù„ŰčŰ§Ù…Ű©"; +"Hide Splash Screen" = "Ű„ŰźÙŰ§ŰĄ ێۧێ۩ Ű§Ù„ŰšŰŻŰ§ÙŠŰ©"; +"HLS video downloading." = "ŰȘنŰČيل ÙÙŠŰŻÙŠÙˆ HLS."; +"Hold Speed" = "۳۱ŰčŰ© Ű§Ù„Ű¶ŰșŰ· Ű§Ù„Ù…Ű·ÙˆÙ„"; + +/* Info */ +"Info" = "مŰčÙ„ÙˆÙ…Ű§ŰȘ"; +"INFOS" = "مŰčÙ„ÙˆÙ…Ű§ŰȘ"; +"Installed Modules" = "Ű§Ù„ÙˆŰ­ŰŻŰ§ŰȘ Ű§Ù„Ù…Ű«ŰšŰȘŰ©"; +"Interface" = "Ű§Ù„ÙˆŰ§ŰŹÙ‡Ű©"; + +/* Social */ +"Join the Discord" = "Ű§Ù†Ű¶Ù… Ű„Ù„Ù‰ ŰŻÙŠŰłÙƒÙˆŰ±ŰŻ"; + +/* Layout */ +"Landscape Columns" = "ŰŁŰčÙ…ŰŻŰ© Ű§Ù„ÙˆŰ¶Űč Ű§Ù„ŰŁÙÙ‚ÙŠ"; +"Language" = "Ű§Ù„Ù„ŰșŰ©"; +"LESS" = "ŰŁÙ‚Ù„"; + +/* Library */ +"Library" = "Ű§Ù„Ù…ÙƒŰȘۚ۩"; +"License (GPLv3.0)" = "Ű§Ù„Ű±ŰźŰ”Ű© (GPLv3.0)"; +"Light" = "ÙŰ§ŰȘŰ­"; + +/* Loading States */ +"Loading Episode %lld..." = "ŰŹŰ§Ű±ÙŠ ŰȘŰ­Ù…ÙŠÙ„ Ű§Ù„Ű­Ù„Ù‚Ű© %lld..."; +"Loading logs..." = "ŰŹŰ§Ű±ÙŠ ŰȘŰ­Ù…ÙŠÙ„ Ű§Ù„ŰłŰŹÙ„Ű§ŰȘ..."; +"Loading module information..." = "ŰŹŰ§Ű±ÙŠ ŰȘŰ­Ù…ÙŠÙ„ مŰčÙ„ÙˆÙ…Ű§ŰȘ Ű§Ù„ÙˆŰ­ŰŻŰ©..."; +"Loading Stream" = "ŰŹŰ§Ű±ÙŠ ŰȘŰ­Ù…ÙŠÙ„ Ű§Ù„ŰšŰ«"; + +/* Logging */ +"Log Debug Info" = "ŰȘŰłŰŹÙŠÙ„ مŰčÙ„ÙˆÙ…Ű§ŰȘ ŰȘŰ”Ű­ÙŠŰ­ Ű§Ù„ŰŁŰźŰ·Ű§ŰĄ"; +"Log Filters" = "Ù…Ű±ŰŽŰ­Ű§ŰȘ Ű§Ù„ŰłŰŹÙ„"; +"Log In with AniList" = "ŰȘŰłŰŹÙŠÙ„ Ű§Ù„ŰŻŰźÙˆÙ„ ۚۧ۳ŰȘŰźŰŻŰ§Ù… AniList"; +"Log In with Trakt" = "ŰȘŰłŰŹÙŠÙ„ Ű§Ù„ŰŻŰźÙˆÙ„ ۚۧ۳ŰȘŰźŰŻŰ§Ù… Trakt"; +"Log Out from AniList" = "ŰȘŰłŰŹÙŠÙ„ Ű§Ù„ŰźŰ±ÙˆŰŹ من AniList"; +"Log Out from Trakt" = "ŰȘŰłŰŹÙŠÙ„ Ű§Ù„ŰźŰ±ÙˆŰŹ من Trakt"; +"Log Types" = "ŰŁÙ†ÙˆŰ§Űč Ű§Ù„ŰłŰŹÙ„"; +"Logged in as" = "ŰȘم ŰȘŰłŰŹÙŠÙ„ Ű§Ù„ŰŻŰźÙˆÙ„ ŰšŰ§ŰłÙ…"; +"Logged in as " = "ŰȘم ŰȘŰłŰŹÙŠÙ„ Ű§Ù„ŰŻŰźÙˆÙ„ ŰšŰ§ŰłÙ… "; + +/* Logs and Settings */ +"Logs" = "Ű§Ù„ŰłŰŹÙ„Ű§ŰȘ"; +"Long press Skip" = "۶ŰșŰ· Ù…Ű·ÙˆÙ„ للŰȘŰźŰ·ÙŠ"; +"MAIN" = "Ű§Ù„Ű±ŰŠÙŠŰłÙŠŰ©"; +"Main Developer" = "Ű§Ù„Ù…Ű·ÙˆŰ± Ű§Ù„Ű±ŰŠÙŠŰłÙŠ"; +"MAIN SETTINGS" = "Ű§Ù„Ű„ŰčۯۧۯۧŰȘ Ű§Ù„Ű±ŰŠÙŠŰłÙŠŰ©"; + +/* Media Actions */ +"Mark All Previous Watched" = "ŰȘمييŰČ ÙƒÙ„ Ù…Ű§ ŰłŰšÙ‚ ÙƒÙ…ŰŽŰ§Ù‡ŰŻ"; +"Mark as Watched" = "ŰȘمييŰČ ÙƒÙ…ŰŽŰ§Ù‡ŰŻ"; +"Mark Episode as Watched" = "ŰȘمييŰČ Ű§Ù„Ű­Ù„Ù‚Ű© ÙƒÙ…ŰŽŰ§Ù‡ŰŻŰ©"; +"Mark Previous Episodes as Watched" = "ŰȘمييŰČ Ű§Ù„Ű­Ù„Ù‚Ű§ŰȘ Ű§Ù„ŰłŰ§ŰšÙ‚Ű© ÙƒÙ…ŰŽŰ§Ù‡ŰŻŰ©"; +"Mark watched" = "ŰȘمييŰČ ÙƒÙ…ŰŽŰ§Ù‡ŰŻ"; +"Match with AniList" = "Ù…Ű·Ű§ŰšÙ‚Ű© مŰč AniList"; +"Match with TMDB" = "Ù…Ű·Ű§ŰšÙ‚Ű© مŰč TMDB"; +"Matched ID: %lld" = "Ű§Ù„Ù…ŰčŰ±Ù‘Ù Ű§Ù„Ù…Ű·Ű§ŰšÙ‚: %lld"; +"Matched with: %@" = "ŰȘمŰȘ Ű§Ù„Ù…Ű·Ű§ŰšÙ‚Ű© مŰč: %@"; +"Max Concurrent Downloads" = "Ű§Ù„Ű­ŰŻ Ű§Ù„ŰŁÙ‚Ű”Ù‰ للŰȘنŰČÙŠÙ„Ű§ŰȘ Ű§Ù„Ù…ŰȘŰČŰ§Ù…Ù†Ű©"; + +/* Media Interface */ +"Media Grid Layout" = "ŰȘŰźŰ·ÙŠŰ· ŰŽŰšÙƒŰ© Ű§Ù„Ù…Ű­ŰȘوى"; +"Media Player" = "Ù…ŰŽŰșل Ű§Ù„Ù…Ű­ŰȘوى"; +"Media View" = "Űč۱۶ Ű§Ù„Ù…Ű­ŰȘوى"; +"Metadata Provider" = "مŰČÙˆŰŻ Ű§Ù„ŰšÙŠŰ§Ù†Ű§ŰȘ Ű§Ù„ÙˆŰ”ÙÙŠŰ©"; +"Metadata Providers Order" = "ŰȘ۱ŰȘÙŠŰš مŰČÙˆŰŻÙŠ Ű§Ù„ŰšÙŠŰ§Ù†Ű§ŰȘ Ű§Ù„ÙˆŰ”ÙÙŠŰ©"; +"Module Removed" = "ŰȘمŰȘ Ű„ŰČŰ§Ù„Ű© Ű§Ù„ÙˆŰ­ŰŻŰ©"; +"Modules" = "Ű§Ù„ÙˆŰ­ŰŻŰ§ŰȘ"; + +/* Headers */ +"MODULES" = "Ű§Ù„ÙˆŰ­ŰŻŰ§ŰȘ"; +"MORE" = "Ű§Ù„Ù…ŰČÙŠŰŻ"; + +/* Status Messages */ +"No Active Downloads" = "Ù„Ű§ ŰȘÙˆŰŹŰŻ ŰȘنŰČÙŠÙ„Ű§ŰȘ Ù†ŰŽŰ·Ű©"; +"No AniList matches found" = "لم يŰȘم Ű§Ù„ŰčŰ«ÙˆŰ± Űčلى Ù…Ű·Ű§ŰšÙ‚Ű§ŰȘ في AniList"; +"No Data Available" = "Ù„Ű§ ŰȘÙˆŰŹŰŻ ŰšÙŠŰ§Ù†Ű§ŰȘ مŰȘۭۧ۩"; +"No Downloads" = "Ù„Ű§ ŰȘÙˆŰŹŰŻ ŰȘنŰČÙŠÙ„Ű§ŰȘ"; +"No episodes available" = "Ù„Ű§ ŰȘÙˆŰŹŰŻ Ű­Ù„Ù‚Ű§ŰȘ مŰȘۭۧ۩"; +"No Episodes Available" = "Ù„Ű§ ŰȘÙˆŰŹŰŻ Ű­Ù„Ù‚Ű§ŰȘ مŰȘۭۧ۩"; +"No items to continue watching." = "Ù„Ű§ ŰȘÙˆŰŹŰŻ ŰčÙ†Ű§Ű”Ű± لمŰȘۧۚŰčŰ© Ù…ŰŽŰ§Ù‡ŰŻŰȘÙ‡Ű§."; +"No matches found" = "لم يŰȘم Ű§Ù„ŰčŰ«ÙˆŰ± Űčلى نŰȘۧۊۏ"; +"No Module Selected" = "لم يŰȘم ŰȘŰ­ŰŻÙŠŰŻ ŰŁÙŠ ÙˆŰ­ŰŻŰ©"; +"No Modules" = "Ù„Ű§ ŰȘÙˆŰŹŰŻ ÙˆŰ­ŰŻŰ§ŰȘ"; +"No Results Found" = "لم يŰȘم Ű§Ù„ŰčŰ«ÙˆŰ± Űčلى نŰȘۧۊۏ"; +"No Search Results Found" = "لم يŰȘم Ű§Ù„ŰčŰ«ÙˆŰ± Űčلى نŰȘۧۊۏ ۭۚ۫"; +"Nothing to Continue Watching" = "Ù„Ű§ ŰŽÙŠŰĄ لمŰȘۧۚŰčŰ© Ù…ŰŽŰ§Ù‡ŰŻŰȘه"; + +/* Notes and Messages */ +"Note that the modules will be replaced only if there is a different version string inside the JSON file." = "Ù…Ù„Ű§Ű­ŰžŰ©: ŰłÙŠŰȘم ۧ۳ŰȘŰšŰŻŰ§Ù„ Ű§Ù„ÙˆŰ­ŰŻŰ§ŰȘ ÙÙ‚Ű· في Ű­Ű§Ù„Ű© ÙˆŰŹÙˆŰŻ ŰłÙ„ŰłÙ„Ű© ۄ۔ۯۧ۱ Ù…ŰźŰȘÙ„ÙŰ© ŰŻŰ§ŰźÙ„ ملف JSON."; + +/* Actions */ +"OK" = "Ù…ÙˆŰ§ÙÙ‚"; +"Open Community Library" = "فŰȘŰ­ مكŰȘۚ۩ Ű§Ù„Ù…ŰŹŰȘمŰč"; + +/* External Services */ +"Open in AniList" = "فŰȘŰ­ في AniList"; +"Original Poster" = "Ű§Ù„Ù…Ù„Ű”Ù‚ Ű§Ù„ŰŁŰ”Ù„ÙŠ"; + +/* Playback */ +"Paused" = "مŰȘوقف Ù…Ű€Ù‚ŰȘÙ‹Ű§"; +"Play" = "ŰȘŰŽŰșيل"; +"Player" = "Ű§Ù„Ù…ŰŽŰșل"; + +/* System Messages */ +"Please restart the app to apply the language change." = "ÙŠŰ±ŰŹÙ‰ Ű„Űčۧۯ۩ ŰȘŰŽŰșيل Ű§Ù„ŰȘŰ·ŰšÙŠÙ‚ لŰȘŰ·ŰšÙŠÙ‚ ŰȘŰșÙŠÙŠŰ± Ű§Ù„Ù„ŰșŰ©."; +"Please select a module from settings" = "ÙŠŰ±ŰŹÙ‰ ŰȘŰ­ŰŻÙŠŰŻ ÙˆŰ­ŰŻŰ© من Ű§Ù„Ű„ŰčۯۧۯۧŰȘ"; + +/* Interface */ +"Portrait Columns" = "ŰŁŰčÙ…ŰŻŰ© Ű§Ù„ÙˆŰ¶Űč Ű§Ù„Ű±ŰŁŰłÙŠ"; +"Progress bar Marker Color" = "لون ŰčÙ„Ű§Ù…Ű© ŰŽŰ±ÙŠŰ· Ű§Ù„ŰȘÙ‚ŰŻÙ…"; +"Provider: %@" = "Ű§Ù„Ù…ŰČÙˆŰŻ: %@"; + +/* Queue */ +"Queue" = "Ù‚Ű§ŰŠÙ…Ű© Ű§Ù„Ű§Ù†ŰȘ۞ۧ۱"; +"Queued" = "في Ù‚Ű§ŰŠÙ…Ű© Ű§Ù„Ű§Ù†ŰȘ۞ۧ۱"; + +/* Content */ +"Recently watched content will appear here." = "ŰłÙŠŰžÙ‡Ű± Ű§Ù„Ù…Ű­ŰȘوى Ű§Ù„Ű°ÙŠ ŰȘمŰȘ Ù…ŰŽŰ§Ù‡ŰŻŰȘه Ù…Ű€ŰźŰ±Ù‹Ű§ Ù‡Ù†Ű§."; + +/* Settings */ +"Refresh Modules on Launch" = "ŰȘŰ­ŰŻÙŠŰ« Ű§Ù„ÙˆŰ­ŰŻŰ§ŰȘ ŰčÙ†ŰŻ Ű§Ù„ŰȘŰŽŰșيل"; +"Refresh Storage Info" = "ŰȘŰ­ŰŻÙŠŰ« مŰčÙ„ÙˆÙ…Ű§ŰȘ Ű§Ù„ŰȘŰźŰČين"; +"Remember Playback speed" = "ŰȘŰ°ÙƒŰ± ۳۱ŰčŰ© Ű§Ù„ŰȘŰŽŰșيل"; + +/* Actions */ +"Remove" = "Ű„ŰČŰ§Ù„Ű©"; +"Remove All Cache" = "Ű„ŰČŰ§Ù„Ű© كل Ű°Ű§ÙƒŰ±Ű© Ű§Ù„ŰȘŰźŰČين Ű§Ù„Ù…Ű€Ù‚ŰȘ"; + +/* File Management */ +"Remove All Documents" = "Ű„ŰČŰ§Ù„Ű© كل Ű§Ù„Ù…ŰłŰȘÙ†ŰŻŰ§ŰȘ"; +"Remove Documents" = "Ű„ŰČŰ§Ù„Ű© Ű§Ù„Ù…ŰłŰȘÙ†ŰŻŰ§ŰȘ"; +"Remove Downloaded Media" = "Ű„ŰČŰ§Ù„Ű© Ű§Ù„Ù…Ű­ŰȘوى Ű§Ù„Ù…Ù†ŰČل"; +"Remove Downloads" = "Ű„ŰČŰ§Ù„Ű© Ű§Ù„ŰȘنŰČÙŠÙ„Ű§ŰȘ"; +"Remove from Bookmarks" = "Ű„ŰČŰ§Ù„Ű© من Ű§Ù„ŰčÙ†Ű§Ű”Ű± Ű§Ù„Ù…Ű­ÙÙˆŰžŰ©"; +"Remove Item" = "Ű„ŰČŰ§Ù„Ű© Ű§Ù„ŰčÙ†Ű”Ű±"; + +/* Support */ +"Report an Issue" = "Ű§Ù„Ű„ŰšÙ„Ű§Űș Űčن Ù…ŰŽÙƒÙ„Ű©"; + +/* Reset Options */ +"Reset" = "Ű„Űčۧۯ۩ ŰȘŰčيين"; +"Reset AniList ID" = "Ű„Űčۧۯ۩ ŰȘŰčيين مŰčŰ±Ù‘Ù AniList"; +"Reset Episode Progress" = "Ű„Űčۧۯ۩ ŰȘŰčيين ŰȘÙ‚ŰŻÙ… Ű§Ù„Ű­Ù„Ù‚Ű©"; +"Reset progress" = "Ű„Űčۧۯ۩ ŰȘŰčيين Ű§Ù„ŰȘÙ‚ŰŻÙ…"; +"Reset Progress" = "Ű„Űčۧۯ۩ ŰȘŰčيين Ű§Ù„ŰȘÙ‚ŰŻÙ…"; + +/* System */ +"Restart Required" = "Ű„Űčۧۯ۩ Ű§Ù„ŰȘŰŽŰșيل Ù…Ű·Ù„ÙˆŰšŰ©"; +"Running Sora %@ - cranci1" = "يŰčمل Sora %@ - ŰšÙˆŰ§ŰłŰ·Ű© cranci1"; + +/* Actions */ +"Save" = "Ű­ÙŰž"; +"Search" = "ۭۚ۫"; + +/* Search */ +"Search downloads" = "ۭۚ۫ في Ű§Ù„ŰȘنŰČÙŠÙ„Ű§ŰȘ"; +"Search for something..." = "ۭۧۚ۫ Űčن ŰŽÙŠŰĄ Ù…Ű§..."; +"Search..." = "ۭۚ۫..."; + +/* Content */ +"Season %d" = "Ű§Ù„Ù…ÙˆŰłÙ… %d"; +"Season %lld" = "Ű§Ù„Ù…ÙˆŰłÙ… %lld"; +"Segments Color" = "لون Ű§Ù„ŰŁŰŹŰČۧۥ"; + +/* Modules */ +"Select Module" = "ŰȘŰ­ŰŻÙŠŰŻ ÙˆŰ­ŰŻŰ©"; +"Set Custom AniList ID" = "ŰȘŰčيين مŰčŰ±Ù‘Ù AniList Ù…ŰźŰ”Ű”"; + +/* Interface */ +"Settings" = "Ű§Ù„Ű„ŰčۯۧۯۧŰȘ"; +"Shadow" = "ŰžÙ„"; +"Show More (%lld more characters)" = "Űč۱۶ Ű§Ù„Ù…ŰČÙŠŰŻ (%lld ŰŁŰ­Ű±Ù Ű„Ű¶Ű§ÙÙŠŰ©)"; +"Show PiP Button" = "Ű„ŰžÙ‡Ű§Ű± ŰČ۱ Ű”ÙˆŰ±Ű© ŰŻŰ§ŰźÙ„ Ű”ÙˆŰ±Ű©"; +"Show Skip 85s Button" = "Ű„ŰžÙ‡Ű§Ű± ŰČ۱ ŰȘŰźŰ·ÙŠ 85 Ű«Ű§Ù†ÙŠŰ©"; +"Show Skip Intro / Outro Buttons" = "Ű„ŰžÙ‡Ű§Ű± ŰŁŰČ۱ۧ۱ ŰȘŰźŰ·ÙŠ Ű§Ù„Ù…Ù‚ŰŻÙ…Ű© / Ű§Ù„ŰźŰ§ŰȘÙ…Ű©"; +"Shows" = "Ű§Ù„ŰčŰ±ÙˆŰ¶"; +"Size (%@)" = "Ű§Ù„Ű­ŰŹÙ… (%@)"; +"Skip Settings" = "Ű„ŰčۯۧۯۧŰȘ Ű§Ù„ŰȘŰźŰ·ÙŠ"; + +/* Player Features */ +"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments." = "ŰšŰč۶ Ű§Ù„Ù…ÙŠŰČۧŰȘ ŰȘقŰȘ۔۱ Űčلى Ù…ŰŽŰșل Sora ÙˆŰ§Ù„Ù…ŰŽŰșل Ű§Ù„Ű§ÙŰȘŰ±Ű§Ű¶ÙŠŰŒ Ù…Ű«Ù„ ÙŰ±Ű¶ Ű§Ù„ÙˆŰ¶Űč Ű§Ù„ŰŁÙÙ‚ÙŠŰŒ ÙˆŰłŰ±ŰčŰ© Ű§Ù„Ű¶ŰșŰ· Ű§Ù„Ù…Ű·ÙˆÙ„ŰŒ وŰČÙŠŰ§ŰŻŰ§ŰȘ Ű§Ù„ŰȘŰźŰ·ÙŠ Ű§Ù„ŰČمني Ű§Ù„Ù…ŰźŰ”Ű”Ű©."; + +/* App Info */ +"Sora" = "Sora"; +"Sora %@ by cranci1" = "Sora %@ ŰšÙˆŰ§ŰłŰ·Ű© 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." = "Sora و cranci1 ŰșÙŠŰ± ŰȘۧۚŰčين لـ AniList ŰŁÙˆ Trakt ŰšŰŁÙŠ ŰŽÙƒÙ„ من Ű§Ù„ŰŁŰŽÙƒŰ§Ù„.\n\nÙŠŰ±ŰŹÙ‰ Ù…Ù„Ű§Ű­ŰžŰ© ŰŁÙ† ŰȘŰ­ŰŻÙŠŰ«Ű§ŰȘ Ű§Ù„ŰȘÙ‚ŰŻÙ… Ù‚ŰŻ Ù„Ű§ ŰȘكون ŰŻÙ‚ÙŠÙ‚Ű© ŰšÙ†ŰłŰšŰ© 100%."; +"Sora GitHub Repository" = "Ù…ŰłŰȘÙˆŰŻŰč Sora Űčلى GitHub"; +"Sora/Sulfur will always remain free with no ADs!" = "ŰłÙŠŰžÙ„ Sora/Sulfur ŰŻŰ§ŰŠÙ…Ù‹Ű§ Ù…ŰŹŰ§Ù†ÙŠÙ‹Ű§ ÙˆŰšŰŻÙˆÙ† Ű„ŰčÙ„Ű§Ù†Ű§ŰȘ!"; + +/* Interface */ +"Sort" = "ÙŰ±ŰČ"; +"Speed Settings" = "Ű„ŰčۯۧۯۧŰȘ Ű§Ù„ŰłŰ±ŰčŰ©"; + +/* Playback */ +"Start Watching" = "ۧۚۯۣ Ű§Ù„Ù…ŰŽŰ§Ù‡ŰŻŰ©"; +"Start Watching Episode %d" = "ۧۚۯۣ Ù…ŰŽŰ§Ù‡ŰŻŰ© Ű§Ù„Ű­Ù„Ù‚Ű© %d"; +"Storage Used" = "Ű§Ù„Ù…ŰłŰ§Ű­Ű© Ű§Ù„Ù…ŰłŰȘŰźŰŻÙ…Ű©"; +"Stream" = "ۚ۫"; +"Streaming and video playback." = "Ű§Ù„ŰšŰ« وŰȘŰŽŰșيل Ű§Ù„ÙÙŠŰŻÙŠÙˆ."; + +/* Subtitles */ +"Subtitle Color" = "لون Ű§Ù„ŰȘŰ±ŰŹÙ…Ű©"; +"Subtitle Settings" = "Ű„ŰčۯۧۯۧŰȘ Ű§Ù„ŰȘŰ±ŰŹÙ…Ű©"; + +/* Sync */ +"Sync anime progress" = "مŰČŰ§Ù…Ù†Ű© ŰȘÙ‚ŰŻÙ… Ű§Ù„ŰŁÙ†Ù…ÙŠ"; +"Sync TV shows progress" = "مŰČŰ§Ù…Ù†Ű© ŰȘÙ‚ŰŻÙ… Ű§Ù„Ù…ŰłÙ„ŰłÙ„Ű§ŰȘ"; + +/* System */ +"System" = "Ű§Ù„Ù†ŰžŰ§Ù…"; + +/* Instructions */ +"Tap a title to override the current match." = "Ű§Ù†Ù‚Ű± Űčلى ŰčÙ†ÙˆŰ§Ù† لŰȘŰŹŰ§ÙˆŰČ Ű§Ù„Ù…Ű·Ű§ŰšÙ‚Ű© Ű§Ù„Ű­Ű§Ù„ÙŠŰ©."; +"Tap Skip" = "Ű§Ù†Ù‚Ű± للŰȘŰźŰ·ÙŠ"; +"Tap to manage your modules" = "Ű§Ù†Ù‚Ű± Ù„Ű„ŰŻŰ§Ű±Ű© ÙˆŰ­ŰŻŰ§ŰȘك"; +"Tap to select a module" = "Ű§Ù†Ù‚Ű± لŰȘŰ­ŰŻÙŠŰŻ ÙˆŰ­ŰŻŰ©"; + +/* App Information */ +"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." = "ŰȘ۳ۧŰčŰŻ Ű°Ű§ÙƒŰ±Ű© Ű§Ù„ŰȘŰźŰČين Ű§Ù„Ù…Ű€Ù‚ŰȘ للŰȘŰ·ŰšÙŠÙ‚ في ŰȘŰ­Ù…ÙŠÙ„ Ű§Ù„Ű”ÙˆŰ± ŰšŰŽÙƒÙ„ ۣ۳۱Űč.\n\nŰłÙŠŰ€ŰŻÙŠ Ù…ŰłŰ­ Ù…ŰŹÙ„ŰŻ Ű§Ù„Ù…ŰłŰȘÙ†ŰŻŰ§ŰȘ Ű„Ù„Ù‰ Ű­Ű°Ù ŰŹÙ…ÙŠŰč Ű§Ù„ÙˆŰ­ŰŻŰ§ŰȘ Ű§Ù„ŰȘي ŰȘم ŰȘنŰČÙŠÙ„Ù‡Ű§.\n\nÙ„Ű§ ŰȘقم ŰšÙ…Ű­Ùˆ ŰšÙŠŰ§Ù†Ű§ŰȘ Ű§Ù„ŰȘŰ·ŰšÙŠÙ‚ Ű„Ù„Ű§ ۄ۰ۧ كنŰȘ ŰȘفهم Ű§Ù„ŰčÙˆŰ§Ù‚Űš — ÙÙ‚ŰŻ يŰȘ۳ۚۚ Ű°Ù„Ùƒ في ŰȘŰčŰ·Ù„ Ű§Ù„ŰȘŰ·ŰšÙŠÙ‚."; +"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." = "يŰȘŰ­ÙƒÙ… Ù†Ű·Ű§Ù‚ Ű§Ù„Ű­Ù„Ù‚Ű§ŰȘ في ŰčŰŻŰŻ Ű§Ù„Ű­Ù„Ù‚Ű§ŰȘ Ű§Ù„ŰȘي ŰȘŰžÙ‡Ű± في كل Ű”ÙŰ­Ű©. يŰȘم ŰȘŰŹÙ…ÙŠŰč Ű§Ù„Ű­Ù„Ù‚Ű§ŰȘ في Ù…ŰŹÙ…ÙˆŰčۧŰȘ (Ù…Ű«Ù„ 1-25ی 26-50ی ÙˆÙ‡ÙƒŰ°Ű§)ی Ù…Ù…Ű§ يŰȘÙŠŰ­ لك Ű§Ù„ŰȘنقل ŰšÙŠÙ†Ù‡Ű§ ŰšŰłÙ‡ÙˆÙ„Ű© ŰŁÙƒŰšŰ±.\n\nŰšŰ§Ù„Ù†ŰłŰšŰ© Ù„ŰšÙŠŰ§Ù†Ű§ŰȘ Ű§Ù„Ű­Ù„Ù‚Ű© Ű§Ù„ÙˆŰ”ÙÙŠŰ©ŰŒ ÙŰ„Ù†Ù‡Ű§ ŰȘŰŽÙŠŰ± Ű„Ù„Ù‰ Ű§Ù„Ű”ÙˆŰ±Ű© Ű§Ù„Ù…Ű”Űș۱۩ Ù„Ù„Ű­Ù„Ù‚Ű© وŰčÙ†ÙˆŰ§Ù†Ù‡Ű§ŰŒ Ű­ÙŠŰ« يمكن ŰŁÙ† ŰȘŰ­ŰȘوي ŰŁŰ­ÙŠŰ§Ù†Ù‹Ű§ Űčلى Ű­Ű±Ù‚ Ù„Ù„ŰŁŰ­ŰŻŰ§Ű«."; +"The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases." = "Ù‚ŰŻÙ…ŰȘ Ű§Ù„ÙˆŰ­ŰŻŰ© Ű­Ù„Ù‚Ű© ÙˆŰ§Ű­ŰŻŰ© ÙÙ‚Ű·ŰŒ ومن Ű§Ù„Ù…Ű±ŰŹŰ­ ŰŁÙ† يكون Ù‡Ű°Ű§ ÙÙŠÙ„Ù…Ù‹Ű§ŰŒ Ù„Ű°Ù„Ùƒ Ù‚Ű±Ű±Ù†Ű§ Ű„Ù†ŰŽŰ§ŰĄ ێۧێۧŰȘ Ù…Ù†ÙŰ”Ù„Ű© Ù„Ù‡Ű°Ù‡ Ű§Ù„Ű­Ű§Ù„Ű§ŰȘ."; + +/* Interface */ +"Thumbnails Width" = "Űč۱۶ Ű§Ù„Ű”ÙˆŰ± Ű§Ù„Ù…Ű”Űș۱۩"; +"TMDB Match" = "Ù…Ű·Ű§ŰšÙ‚Ű© TMDB"; +"Trackers" = "Ű§Ù„Ù…ŰȘŰȘŰšŰčۧŰȘ"; +"Trakt" = "Trakt"; +"Trakt.tv" = "Trakt.tv"; + +/* Search */ +"Try different keywords" = "ۏ۱ۚ ÙƒÙ„Ù…Ű§ŰȘ مفŰȘŰ§Ű­ÙŠŰ© Ù…ŰźŰȘÙ„ÙŰ©"; +"Try different search terms" = "ۏ۱ۚ Ù…Ű”Ű·Ù„Ű­Ű§ŰȘ ۭۚ۫ Ù…ŰźŰȘÙ„ÙŰ©"; + +/* Player Controls */ +"Two Finger Hold for Pause" = "ۧ۶ŰșŰ· ۚۄ۔ۚŰčين Ù„Ù„Ű„ÙŠÙ‚Ű§Ù Ű§Ù„Ù…Ű€Ù‚ŰȘ"; +"Unable to fetch matches. Please try again later." = "ŰȘŰč۰۱ ŰŹÙ„Űš Ű§Ù„Ù…Ű·Ű§ŰšÙ‚Ű§ŰȘ. ÙŠŰ±ŰŹÙ‰ Ű§Ù„Ù…Ű­Ű§ÙˆÙ„Ű© Ù…Ű±Ű© ŰŁŰźŰ±Ù‰ Ù„Ű§Ű­Ù‚Ù‹Ű§."; +"Use TMDB Poster Image" = "ۧ۳ŰȘŰźŰŻŰ§Ù… Ű”ÙˆŰ±Ű© Ù…Ù„Ű”Ù‚ TMDB"; + +/* Version */ +"v%@" = "v%@"; +"Video Player" = "Ù…ŰŽŰșل Ű§Ù„ÙÙŠŰŻÙŠÙˆ"; + +/* Video Settings */ +"Video Quality Preferences" = "ŰȘÙŰ¶ÙŠÙ„Ű§ŰȘ ŰŹÙˆŰŻŰ© Ű§Ù„ÙÙŠŰŻÙŠÙˆ"; +"View All" = "Űč۱۶ Ű§Ù„ÙƒÙ„"; +"Watched" = "ŰȘمŰȘ Ù…ŰŽŰ§Ù‡ŰŻŰȘه"; +"Why am I not seeing any episodes?" = "Ù„Ù…Ű§Ű°Ű§ Ù„Ű§ ŰŁŰ±Ù‰ ŰŁÙŠ Ű­Ù„Ù‚Ű§ŰȘ۟"; +"WiFi Quality" = "ŰŹÙˆŰŻŰ© WiFi"; + +/* User Status */ +"You are not logged in" = "ŰŁÙ†ŰȘ ŰșÙŠŰ± Ù…ŰłŰŹÙ„ Ű§Ù„ŰŻŰźÙˆÙ„"; +"You have no items saved." = "Ù„ÙŠŰł Ù„ŰŻÙŠÙƒ ŰčÙ†Ű§Ű”Ű± Ù…Ű­ÙÙˆŰžŰ©."; +"Your downloaded episodes will appear here" = "ŰłŰȘŰžÙ‡Ű± Ű­Ù„Ù‚Ű§ŰȘك Ű§Ù„Ù…Ù†ŰČÙ‘Ù„Ű© Ù‡Ù†Ű§"; +"Your recently watched content will appear here" = "ŰłÙŠŰžÙ‡Ű± Ű§Ù„Ù…Ű­ŰȘوى Ű§Ù„Ű°ÙŠ ŰŽŰ§Ù‡ŰŻŰȘه Ù…Ű€ŰźŰ±Ù‹Ű§ Ù‡Ù†Ű§"; + +/* Download Settings */ +"Download Settings" = "Ű„ŰčۯۧۯۧŰȘ Ű§Ù„ŰȘنŰČيل"; +"Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources." = "يŰȘŰ­ÙƒÙ… Ű§Ù„Ű­ŰŻ Ű§Ù„ŰŁÙ‚Ű”Ù‰ للŰȘنŰČÙŠÙ„Ű§ŰȘ Ű§Ù„Ù…ŰȘŰČŰ§Ù…Ù†Ű© في ŰčŰŻŰŻ Ű§Ù„Ű­Ù„Ù‚Ű§ŰȘ Ű§Ù„ŰȘي يمكن ŰȘنŰČÙŠÙ„Ù‡Ű§ في وقŰȘ ÙˆŰ§Ű­ŰŻ. Ù‚ŰŻ ŰȘŰłŰȘهلك Ű§Ù„Ù‚ÙŠÙ… Ű§Ù„ŰŁŰčلى Ű§Ù„Ù…ŰČÙŠŰŻ من Ű§Ù„Ù†Ű·Ű§Ù‚ Ű§Ù„ŰȘŰ±ŰŻŰŻÙŠ ÙˆÙ…ÙˆŰ§Ű±ŰŻ Ű§Ù„ŰŹÙ‡Ű§ŰČ."; +"Quality" = "Ű§Ù„ŰŹÙˆŰŻŰ©"; +"Max Concurrent Downloads" = "Ű§Ù„Ű­ŰŻ Ű§Ù„ŰŁÙ‚Ű”Ù‰ للŰȘنŰČÙŠÙ„Ű§ŰȘ Ű§Ù„Ù…ŰȘŰČŰ§Ù…Ù†Ű©"; +"Allow Cellular Downloads" = "Ű§Ù„ŰłÙ…Ű§Ű­ ŰšŰ§Ù„ŰȘنŰČÙŠÙ„Ű§ŰȘ Űčۚ۱ ŰšÙŠŰ§Ù†Ű§ŰȘ Ű§Ù„ŰŹÙˆŰ§Ù„"; +"Quality Information" = "مŰčÙ„ÙˆÙ…Ű§ŰȘ Ű§Ù„ŰŹÙˆŰŻŰ©"; + +/* Storage */ +"Storage Management" = "ۄۯۧ۱۩ Ű§Ù„ŰȘŰźŰČين"; +"Storage Used" = "Ű§Ù„Ù…ŰłŰ§Ű­Ű© Ű§Ù„Ù…ŰłŰȘŰźŰŻÙ…Ű©"; +"Library cleared successfully" = "ŰȘم Ù…ŰłŰ­ Ű§Ù„Ù…ÙƒŰȘۚ۩ ŰšÙ†ŰŹŰ§Ű­"; +"All downloads deleted successfully" = "ŰȘم Ű­Ű°Ù ŰŹÙ…ÙŠŰč Ű§Ù„ŰȘنŰČÙŠÙ„Ű§ŰȘ ŰšÙ†ŰŹŰ§Ű­"; \ No newline at end of file diff --git a/Sora/en.lproj/Localizable.strings b/Sora/en.lproj/Localizable.strings new file mode 100644 index 0000000..19cd7cf --- /dev/null +++ b/Sora/en.lproj/Localizable.strings @@ -0,0 +1,384 @@ +/* General */ +"About" = "About"; +"About Sora" = "About Sora"; +"Active" = "Active"; +"Active Downloads" = "Active Downloads"; +"Actively downloading media can be tracked from here." = "Actively downloading media can be tracked from here."; +"Add Module" = "Add Module"; +"Adjust the number of media items per row in portrait and landscape modes." = "Adjust the number of media items per row in portrait and landscape modes."; +"Advanced" = "Advanced"; +"AKA Sulfur" = "AKA Sulfur"; +"All Bookmarks" = "All Bookmarks"; +"All Watching" = "All Watching"; +"Also known as Sulfur" = "Also known as Sulfur"; +"AniList" = "AniList"; +"AniList ID" = "AniList ID"; +"AniList Match" = "AniList Match"; +"AniList.co" = "AniList.co"; +"Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time." = "Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time."; +"App Info" = "App Info"; +"App Language" = "App Language"; +"App Storage" = "App Storage"; +"Appearance" = "Appearance"; + +/* Alerts and Actions */ +"Are you sure you want to clear all cached data? This will help free up storage space." = "Are you sure you want to clear all cached data? This will help free up storage space."; +"Are you sure you want to delete '%@'?" = "Are you sure you want to delete '%@'?"; +"Are you sure you want to delete all %1$d episodes in '%2$@'?" = "Are you sure you want to delete all %1$d episodes in '%2$@'?"; +"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." = "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."; +"Are you sure you want to erase all app data? This action cannot be undone." = "Are you sure you want to erase all app data? This action cannot be undone."; + +/* Features */ +"Background Enabled" = "Background Enabled"; +"Bookmark items for an easier access later." = "Bookmark items for an easier access later."; +"Bookmarks" = "Bookmarks"; +"Bottom Padding" = "Bottom Padding"; +"Cancel" = "Cancel"; +"Cellular Quality" = "Cellular Quality"; +"Check out some community modules here!" = "Check out some community modules here!"; +"Choose preferred video resolution for WiFi and cellular connections. Higher resolutions use more data but provide better quality." = "Choose preferred video resolution for WiFi and cellular connections. Higher resolutions use more data but provide better quality."; +"Clear" = "Clear"; +"Clear All Downloads" = "Clear All Downloads"; +"Clear Cache" = "Clear Cache"; +"Clear Library Only" = "Clear Library Only"; +"Clear Logs" = "Clear Logs"; +"Click the plus button to add a module!" = "Click the plus button to add a module!"; +"Continue Watching" = "Continue Watching"; +"Continue Watching Episode %d" = "Continue Watching Episode %d"; +"Contributors" = "Contributors"; +"Copied to Clipboard" = "Copied to Clipboard"; +"Copy to Clipboard" = "Copy to Clipboard"; +"Copy URL" = "Copy URL"; + +/* Episodes */ +"%lld Episodes" = "%lld Episodes"; +"%lld of %lld" = "%1$lld of %2$lld"; +"%lld-%lld" = "%1$lld-%2$lld"; +"%lld%% seen" = "%lld%% seen"; +"Episode %lld" = "Episode %lld"; +"Episodes" = "Episodes"; +"Episodes might not be available yet or there could be an issue with the source." = "Episodes might not be available yet or there could be an issue with the source."; +"Episodes Range" = "Episodes Range"; + +/* System */ +"cranci1" = "cranci1"; +"Dark" = "Dark"; +"DATA & LOGS" = "DATA & LOGS"; +"Debug" = "Debug"; +"Debugging and troubleshooting." = "Debugging and troubleshooting."; + +/* Actions */ +"Delete" = "Delete"; +"Delete All" = "Delete All"; +"Delete All Downloads" = "Delete All Downloads"; +"Delete All Episodes" = "Delete All Episodes"; +"Delete Download" = "Delete Download"; +"Delete Episode" = "Delete Episode"; + +/* Player */ +"Double Tap to Seek" = "Double Tap to Seek"; +"Double tapping the screen on it's sides will skip with the short tap setting." = "Double tapping the screen on it's sides will skip with the short tap setting."; + +/* Downloads */ +"Download" = "Download"; +"Download Episode" = "Download Episode"; +"Download Summary" = "Download Summary"; +"Download This Episode" = "Download This Episode"; +"Downloaded" = "Downloaded"; +"Downloaded Shows" = "Downloaded Shows"; +"Downloading" = "Downloading"; +"Downloads" = "Downloads"; + +/* Settings */ +"Enable Analytics" = "Enable Analytics"; +"Enable Subtitles" = "Enable Subtitles"; + +/* Data Management */ +"Erase" = "Erase"; +"Erase all App Data" = "Erase all App Data"; +"Erase App Data" = "Erase App Data"; + +/* Errors */ +"Error" = "Error"; +"Error Fetching Results" = "Error Fetching Results"; +"Errors and critical issues." = "Errors and critical issues."; +"Failed to load contributors" = "Failed to load contributors"; + +/* Features */ +"Fetch Episode metadata" = "Fetch Episode metadata"; +"Files Downloaded" = "Files Downloaded"; +"Font Size" = "Font Size"; + +/* Interface */ +"Force Landscape" = "Force Landscape"; +"General" = "General"; +"General events and activities." = "General events and activities."; +"General Preferences" = "General Preferences"; +"Hide Splash Screen" = "Hide Splash Screen"; +"HLS video downloading." = "HLS video downloading."; +"Hold Speed" = "Hold Speed"; + +/* Info */ +"Info" = "Info"; +"INFOS" = "INFOS"; +"Installed Modules" = "Installed Modules"; +"Interface" = "Interface"; + +/* Social */ +"Join the Discord" = "Join the Discord"; + +/* Layout */ +"Landscape Columns" = "Landscape Columns"; +"Language" = "Language"; +"LESS" = "LESS"; + +/* Library */ +"Library" = "Library"; +"License (GPLv3.0)" = "License (GPLv3.0)"; +"Light" = "Light"; + +/* Loading States */ +"Loading Episode %lld..." = "Loading Episode %lld..."; +"Loading logs..." = "Loading logs..."; +"Loading module information..." = "Loading module information..."; +"Loading Stream" = "Loading Stream"; + +/* Logging */ +"Log Debug Info" = "Log Debug Info"; +"Log Filters" = "Log Filters"; +"Log In with AniList" = "Log In with AniList"; +"Log In with Trakt" = "Log In with Trakt"; +"Log Out from AniList" = "Log Out from AniList"; +"Log Out from Trakt" = "Log Out from Trakt"; +"Log Types" = "Log Types"; +"Logged in as" = "Logged in as"; +"Logged in as " = "Logged in as "; + +/* Logs and Settings */ +"Logs" = "Logs"; +"Long press Skip" = "Long press Skip"; +"MAIN" = "Main Settings"; +"Main Developer" = "Main Developer"; +"MAIN SETTINGS" = "MAIN SETTINGS"; + +/* Media Actions */ +"Mark All Previous Watched" = "Mark All Previous Watched"; +"Mark as Watched" = "Mark as Watched"; +"Mark Episode as Watched" = "Mark Episode as Watched"; +"Mark Previous Episodes as Watched" = "Mark Previous Episodes as Watched"; +"Mark watched" = "Mark watched"; +"Match with AniList" = "Match with AniList"; +"Match with TMDB" = "Match with TMDB"; +"Matched ID: %lld" = "Matched ID: %lld"; +"Matched with: %@" = "Matched with: %@"; +"Max Concurrent Downloads" = "Max Concurrent Downloads"; + +/* Media Interface */ +"Media Grid Layout" = "Media Grid Layout"; +"Media Player" = "Media Player"; +"Media View" = "Media View"; +"Metadata Provider" = "Metadata Provider"; +"Metadata Providers Order" = "Metadata Providers Order"; +"Module Removed" = "Module Removed"; +"Modules" = "Modules"; + +/* Headers */ +"MODULES" = "MODULES"; +"MORE" = "MORE"; + +/* Status Messages */ +"No Active Downloads" = "No Active Downloads"; +"No AniList matches found" = "No AniList matches found"; +"No Data Available" = "No Data Available"; +"No Downloads" = "No Downloads"; +"No episodes available" = "No episodes available"; +"No Episodes Available" = "No Episodes Available"; +"No items to continue watching." = "No items to continue watching."; +"No matches found" = "No matches found"; +"No Module Selected" = "No Module Selected"; +"No Modules" = "No Modules"; +"No Results Found" = "No Results Found"; +"No Search Results Found" = "No Search Results Found"; +"Nothing to Continue Watching" = "Nothing to Continue Watching"; + +/* Notes and Messages */ +"Note that the modules will be replaced only if there is a different version string inside the JSON file." = "Note that the modules will be replaced only if there is a different version string inside the JSON file."; + +/* Actions */ +"OK" = "OK"; +"Open Community Library" = "Open Community Library"; + +/* External Services */ +"Open in AniList" = "Open in AniList"; +"Original Poster" = "Original Poster"; + +/* Playback */ +"Paused" = "Paused"; +"Play" = "Play"; +"Player" = "Player"; + +/* System Messages */ +"Please restart the app to apply the language change." = "Please restart the app to apply the language change."; +"Please select a module from settings" = "Please select a module from settings"; + +/* Interface */ +"Portrait Columns" = "Portrait Columns"; +"Progress bar Marker Color" = "Progress bar Marker Color"; +"Provider: %@" = "Provider: %@"; + +/* Queue */ +"Queue" = "Queue"; +"Queued" = "Queued"; + +/* Content */ +"Recently watched content will appear here." = "Recently watched content will appear here."; + +/* Settings */ +"Refresh Modules on Launch" = "Refresh Modules on Launch"; +"Refresh Storage Info" = "Refresh Storage Info"; +"Remember Playback speed" = "Remember Playback speed"; + +/* Actions */ +"Remove" = "Remove"; +"Remove All Cache" = "Remove All Cache"; + +/* File Management */ +"Remove All Documents" = "Remove All Documents"; +"Remove Documents" = "Remove Documents"; +"Remove Downloaded Media" = "Remove Downloaded Media"; +"Remove Downloads" = "Remove Downloads"; +"Remove from Bookmarks" = "Remove from Bookmarks"; +"Remove Item" = "Remove Item"; + +/* Support */ +"Report an Issue" = "Report an Issue"; + +/* Reset Options */ +"Reset" = "Reset"; +"Reset AniList ID" = "Reset AniList ID"; +"Reset Episode Progress" = "Reset Episode Progress"; +"Reset progress" = "Reset progress"; +"Reset Progress" = "Reset Progress"; + +/* System */ +"Restart Required" = "Restart Required"; +"Running Sora %@ - cranci1" = "Running Sora %@ - cranci1"; + +/* Actions */ +"Save" = "Save"; +"Search" = "Search"; + +/* Search */ +"Search downloads" = "Search downloads"; +"Search for something..." = "Search for something..."; +"Search..." = "Search..."; + +/* Content */ +"Season %d" = "Season %d"; +"Season %lld" = "Season %lld"; +"Segments Color" = "Segments Color"; + +/* Modules */ +"Select Module" = "Select Module"; +"Set Custom AniList ID" = "Set Custom AniList ID"; + +/* Interface */ +"Settings" = "Settings"; +"Shadow" = "Shadow"; +"Show More (%lld more characters)" = "Show More (%lld more characters)"; +"Show PiP Button" = "Show PiP Button"; +"Show Skip 85s Button" = "Show Skip 85s Button"; +"Show Skip Intro / Outro Buttons" = "Show Skip Intro / Outro Buttons"; +"Shows" = "Shows"; +"Size (%@)" = "Size (%@)"; +"Skip Settings" = "Skip Settings"; + +/* Player Features */ +"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments." = "Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments."; + +/* App Info */ +"Sora" = "Sora"; +"Sora %@ by cranci1" = "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." = "Sora and cranci1 are not affiliated with AniList or Trakt in any way.\n\nAlso note that progress updates may not be 100% accurate."; +"Sora GitHub Repository" = "Sora GitHub Repository"; +"Sora/Sulfur will always remain free with no ADs!" = "Sora/Sulfur will always remain free with no ADs!"; + +/* Interface */ +"Sort" = "Sort"; +"Speed Settings" = "Speed Settings"; + +/* Playback */ +"Start Watching" = "Start Watching"; +"Start Watching Episode %d" = "Start Watching Episode %d"; +"Storage Used" = "Storage Used"; +"Stream" = "Stream"; +"Streaming and video playback." = "Streaming and video playback."; + +/* Subtitles */ +"Subtitle Color" = "Subtitle Color"; +"Subtitle Settings" = "Subtitle Settings"; + +/* Sync */ +"Sync anime progress" = "Sync anime progress"; +"Sync TV shows progress" = "Sync TV shows progress"; + +/* System */ +"System" = "System"; + +/* Instructions */ +"Tap a title to override the current match." = "Tap a title to override the current match."; +"Tap Skip" = "Tap Skip"; +"Tap to manage your modules" = "Tap to manage your modules"; +"Tap to select a module" = "Tap to select a module"; + +/* App Information */ +"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." = "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."; +"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." = "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."; +"The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases." = "The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases."; + +/* Interface */ +"Thumbnails Width" = "Thumbnails Width"; +"TMDB Match" = "TMDB Match"; +"Trackers" = "Trackers"; +"Trakt" = "Trakt"; +"Trakt.tv" = "Trakt.tv"; + +/* Search */ +"Try different keywords" = "Try different keywords"; +"Try different search terms" = "Try different search terms"; + +/* Player Controls */ +"Two Finger Hold for Pause" = "Two Finger Hold for Pause"; +"Unable to fetch matches. Please try again later." = "Unable to fetch matches. Please try again later."; +"Use TMDB Poster Image" = "Use TMDB Poster Image"; + +/* Version */ +"v%@" = "v%@"; +"Video Player" = "Video Player"; + +/* Video Settings */ +"Video Quality Preferences" = "Video Quality Preferences"; +"View All" = "View All"; +"Watched" = "Watched"; +"Why am I not seeing any episodes?" = "Why am I not seeing any episodes?"; +"WiFi Quality" = "WiFi Quality"; + +/* User Status */ +"You are not logged in" = "You are not logged in"; +"You have no items saved." = "You have no items saved."; +"Your downloaded episodes will appear here" = "Your downloaded episodes will appear here"; +"Your recently watched content will appear here" = "Your recently watched content will appear here"; + +/* Download Settings */ +"Download Settings" = "Download Settings"; +"Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources." = "Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources."; +"Quality" = "Quality"; +"Max Concurrent Downloads" = "Max Concurrent Downloads"; +"Allow Cellular Downloads" = "Allow Cellular Downloads"; +"Quality Information" = "Quality Information"; + +/* Storage */ +"Storage Management" = "Storage Management"; +"Storage Used" = "Storage Used"; +"Library cleared successfully" = "Library cleared successfully"; +"All downloads deleted successfully" = "All downloads deleted successfully"; \ No newline at end of file diff --git a/Sora/fr.lproj/Localizable.strings b/Sora/fr.lproj/Localizable.strings new file mode 100644 index 0000000..2dae36f --- /dev/null +++ b/Sora/fr.lproj/Localizable.strings @@ -0,0 +1,384 @@ +/* General */ +"About" = "À propos"; +"About Sora" = "À propos de Sora"; +"Active" = "Actif"; +"Active Downloads" = "TĂ©lĂ©chargements actifs"; +"Actively downloading media can be tracked from here." = "Les mĂ©dias en cours de tĂ©lĂ©chargement peuvent ĂȘtre suivis ici."; +"Add Module" = "Ajouter un module"; +"Adjust the number of media items per row in portrait and landscape modes." = "Ajustez le nombre d'Ă©lĂ©ments multimĂ©dias par ligne en mode portrait et paysage."; +"Advanced" = "AvancĂ©"; +"AKA Sulfur" = "Aussi connu sous le nom de Sulfur"; +"All Bookmarks" = "Tous les favoris"; +"All Watching" = "Tout ce que je regarde"; +"Also known as Sulfur" = "Aussi connu sous le nom de Sulfur"; +"AniList" = "AniList"; +"AniList ID" = "ID AniList"; +"AniList Match" = "Correspondance AniList"; +"AniList.co" = "AniList.co"; +"Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time." = "Des donnĂ©es anonymes sont collectĂ©es pour amĂ©liorer l'application. Aucune information personnelle n'est collectĂ©e. Ceci peut ĂȘtre dĂ©sactivĂ© Ă  tout moment."; +"App Info" = "Infos sur l'application"; +"App Language" = "Langue de l'application"; +"App Storage" = "Stockage de l'application"; +"Appearance" = "Apparence"; + +/* Alerts and Actions */ +"Are you sure you want to clear all cached data? This will help free up storage space." = "Voulez-vous vraiment vider toutes les donnĂ©es en cache ? Cela aidera Ă  libĂ©rer de l'espace de stockage."; +"Are you sure you want to delete '%@'?" = "Voulez-vous vraiment supprimer '%@' ?"; +"Are you sure you want to delete all %1$d episodes in '%2$@'?" = "Voulez-vous vraiment supprimer les %1$d Ă©pisodes de '%2$@' ?"; +"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." = "Voulez-vous vraiment supprimer tous les fichiers tĂ©lĂ©chargĂ©s ? Vous pouvez choisir de vider uniquement la bibliothĂšque tout en conservant les fichiers tĂ©lĂ©chargĂ©s pour une utilisation future."; +"Are you sure you want to erase all app data? This action cannot be undone." = "Voulez-vous vraiment effacer toutes les donnĂ©es de l'application ? Cette action est irrĂ©versible."; + +/* Features */ +"Background Enabled" = "ActivĂ© en arriĂšre-plan"; +"Bookmark items for an easier access later." = "Mettez des Ă©lĂ©ments en favoris pour un accĂšs plus facile plus tard."; +"Bookmarks" = "Favoris"; +"Bottom Padding" = "Marge intĂ©rieure infĂ©rieure"; +"Cancel" = "Annuler"; +"Cellular Quality" = "QualitĂ© cellulaire"; +"Check out some community modules here!" = "DĂ©couvrez quelques modules communautaires ici !"; +"Choose preferred video resolution for WiFi and cellular connections. Higher resolutions use more data but provide better quality." = "Choisissez la rĂ©solution vidĂ©o prĂ©fĂ©rĂ©e pour les connexions WiFi et cellulaires. Les rĂ©solutions plus Ă©levĂ©es utilisent plus de donnĂ©es mais offrent une meilleure qualitĂ©."; +"Clear" = "Vider"; +"Clear All Downloads" = "Vider tous les tĂ©lĂ©chargements"; +"Clear Cache" = "Vider le cache"; +"Clear Library Only" = "Vider la bibliothĂšque uniquement"; +"Clear Logs" = "Vider les journaux"; +"Click the plus button to add a module!" = "Cliquez sur le bouton plus pour ajouter un module !"; +"Continue Watching" = "Reprendre la lecture"; +"Continue Watching Episode %d" = "Reprendre l'Ă©pisode %d"; +"Contributors" = "Contributeurs"; +"Copied to Clipboard" = "CopiĂ© dans le presse-papiers"; +"Copy to Clipboard" = "Copier dans le presse-papiers"; +"Copy URL" = "Copier l'URL"; + +/* Episodes */ +"%lld Episodes" = "%lld Ă©pisodes"; +"%lld of %lld" = "%1$lld sur %2$lld"; +"%lld-%lld" = "%1$lld-%2$lld"; +"%lld%% seen" = "%lld%% vus"; +"Episode %lld" = "Épisode %lld"; +"Episodes" = "Épisodes"; +"Episodes might not be available yet or there could be an issue with the source." = "Les Ă©pisodes ne sont peut-ĂȘtre pas encore disponibles ou il y a un problĂšme avec la source."; +"Episodes Range" = "Plage d'Ă©pisodes"; + +/* System */ +"cranci1" = "cranci1"; +"Dark" = "Sombre"; +"DATA & LOGS" = "DONNÉES & JOURNEAUX"; +"Debug" = "DĂ©bogage"; +"Debugging and troubleshooting." = "DĂ©bogage et rĂ©solution de problĂšmes."; + +/* Actions */ +"Delete" = "Supprimer"; +"Delete All" = "Tout supprimer"; +"Delete All Downloads" = "Supprimer tous les tĂ©lĂ©chargements"; +"Delete All Episodes" = "Supprimer tous les Ă©pisodes"; +"Delete Download" = "Supprimer le tĂ©lĂ©chargement"; +"Delete Episode" = "Supprimer l'Ă©pisode"; + +/* Player */ +"Double Tap to Seek" = "Touchez deux fois pour avancer"; +"Double tapping the screen on it's sides will skip with the short tap setting." = "Appuyer deux fois sur les cĂŽtĂ©s de l'Ă©cran permet de sauter avec le rĂ©glage de pression courte."; + +/* Downloads */ +"Download" = "TĂ©lĂ©charger"; +"Download Episode" = "TĂ©lĂ©charger l'Ă©pisode"; +"Download Summary" = "RĂ©sumĂ© du tĂ©lĂ©chargement"; +"Download This Episode" = "TĂ©lĂ©charger cet Ă©pisode"; +"Downloaded" = "TĂ©lĂ©chargĂ©"; +"Downloaded Shows" = "SĂ©ries tĂ©lĂ©chargĂ©es"; +"Downloading" = "TĂ©lĂ©chargement en cours"; +"Downloads" = "TĂ©lĂ©chargements"; + +/* Settings */ +"Enable Analytics" = "Activer les analyses"; +"Enable Subtitles" = "Activer les sous-titres"; + +/* Data Management */ +"Erase" = "Effacer"; +"Erase all App Data" = "Effacer toutes les donnĂ©es de l'app"; +"Erase App Data" = "Effacer les donnĂ©es de l'app"; + +/* Errors */ +"Error" = "Erreur"; +"Error Fetching Results" = "Erreur lors de la rĂ©cupĂ©ration des rĂ©sultats"; +"Errors and critical issues." = "Erreurs et problĂšmes critiques."; +"Failed to load contributors" = "Échec du chargement des contributeurs"; + +/* Features */ +"Fetch Episode metadata" = "RĂ©cupĂ©rer les mĂ©tadonnĂ©es de l'Ă©pisode"; +"Files Downloaded" = "Fichiers tĂ©lĂ©chargĂ©s"; +"Font Size" = "Taille de la police"; + +/* Interface */ +"Force Landscape" = "Forcer le mode paysage"; +"General" = "GĂ©nĂ©ral"; +"General events and activities." = "ÉvĂ©nements et activitĂ©s gĂ©nĂ©rales."; +"General Preferences" = "PrĂ©fĂ©rences gĂ©nĂ©rales"; +"Hide Splash Screen" = "Masquer l'Ă©cran de dĂ©marrage"; +"HLS video downloading." = "TĂ©lĂ©chargement de vidĂ©os HLS."; +"Hold Speed" = "Vitesse de maintien"; + +/* Info */ +"Info" = "Infos"; +"INFOS" = "INFOS"; +"Installed Modules" = "Modules installĂ©s"; +"Interface" = "Interface"; + +/* Social */ +"Join the Discord" = "Rejoindre le Discord"; + +/* Layout */ +"Landscape Columns" = "Colonnes en paysage"; +"Language" = "Langue"; +"LESS" = "MOINS"; + +/* Library */ +"Library" = "BibliothĂšque"; +"License (GPLv3.0)" = "Licence (GPLv3.0)"; +"Light" = "Clair"; + +/* Loading States */ +"Loading Episode %lld..." = "Chargement de l'Ă©pisode %lld..."; +"Loading logs..." = "Chargement des journaux..."; +"Loading module information..." = "Chargement des informations du module..."; +"Loading Stream" = "Chargement du flux"; + +/* Logging */ +"Log Debug Info" = "Journaliser les infos de dĂ©bogage"; +"Log Filters" = "Filtres de journal"; +"Log In with AniList" = "Se connecter avec AniList"; +"Log In with Trakt" = "Se connecter avec Trakt"; +"Log Out from AniList" = "Se dĂ©connecter d'AniList"; +"Log Out from Trakt" = "Se dĂ©connecter de Trakt"; +"Log Types" = "Types de journaux"; +"Logged in as" = "ConnectĂ© en tant que"; +"Logged in as " = "ConnectĂ© en tant que "; + +/* Logs and Settings */ +"Logs" = "Journaux"; +"Long press Skip" = "Pression longue pour sauter"; +"MAIN" = "PRINCIPAL"; +"Main Developer" = "DĂ©veloppeur principal"; +"MAIN SETTINGS" = "RÉGLAGES PRINCIPAUX"; + +/* Media Actions */ +"Mark All Previous Watched" = "Marquer tout comme vu"; +"Mark as Watched" = "Marquer comme vu"; +"Mark Episode as Watched" = "Marquer l'Ă©pisode comme vu"; +"Mark Previous Episodes as Watched" = "Marquer les Ă©pisodes prĂ©cĂ©dents comme vus"; +"Mark watched" = "Marquer comme vu"; +"Match with AniList" = "Associer avec AniList"; +"Match with TMDB" = "Associer avec TMDB"; +"Matched ID: %lld" = "ID associĂ© : %lld"; +"Matched with: %@" = "AssociĂ© avec : %@"; +"Max Concurrent Downloads" = "TĂ©lĂ©chargements simultanĂ©s max"; + +/* Media Interface */ +"Media Grid Layout" = "Mise en page de la grille mĂ©dia"; +"Media Player" = "Lecteur multimĂ©dia"; +"Media View" = "Vue multimĂ©dia"; +"Metadata Provider" = "Fournisseur de mĂ©tadonnĂ©es"; +"Metadata Providers Order" = "Ordre des fournisseurs de mĂ©tadonnĂ©es"; +"Module Removed" = "Module supprimĂ©"; +"Modules" = "Modules"; + +/* Headers */ +"MODULES" = "MODULES"; +"MORE" = "PLUS"; + +/* Status Messages */ +"No Active Downloads" = "Aucun tĂ©lĂ©chargement actif"; +"No AniList matches found" = "Aucune correspondance AniList trouvĂ©e"; +"No Data Available" = "Aucune donnĂ©e disponible"; +"No Downloads" = "Aucun tĂ©lĂ©chargement"; +"No episodes available" = "Aucun Ă©pisode disponible"; +"No Episodes Available" = "Aucun Ă©pisode disponible"; +"No items to continue watching." = "Aucun Ă©lĂ©ment Ă  reprendre."; +"No matches found" = "Aucune correspondance trouvĂ©e"; +"No Module Selected" = "Aucun module sĂ©lectionnĂ©"; +"No Modules" = "Aucun module"; +"No Results Found" = "Aucun rĂ©sultat trouvĂ©"; +"No Search Results Found" = "Aucun rĂ©sultat de recherche trouvĂ©"; +"Nothing to Continue Watching" = "Rien Ă  reprendre"; + +/* Notes and Messages */ +"Note that the modules will be replaced only if there is a different version string inside the JSON file." = "Notez que les modules ne seront remplacĂ©s que s'il existe une chaĂźne de version diffĂ©rente dans le fichier JSON."; + +/* Actions */ +"OK" = "OK"; +"Open Community Library" = "Ouvrir la bibliothĂšque communautaire"; + +/* External Services */ +"Open in AniList" = "Ouvrir dans AniList"; +"Original Poster" = "Affiche originale"; + +/* Playback */ +"Paused" = "En pause"; +"Play" = "Lecture"; +"Player" = "Lecteur"; + +/* System Messages */ +"Please restart the app to apply the language change." = "Veuillez redĂ©marrer l'application pour appliquer le changement de langue."; +"Please select a module from settings" = "Veuillez sĂ©lectionner un module dans les paramĂštres"; + +/* Interface */ +"Portrait Columns" = "Colonnes en portrait"; +"Progress bar Marker Color" = "Couleur du marqueur de la barre de progression"; +"Provider: %@" = "Fournisseur : %@"; + +/* Queue */ +"Queue" = "File d'attente"; +"Queued" = "En attente"; + +/* Content */ +"Recently watched content will appear here." = "Le contenu rĂ©cemment visionnĂ© apparaĂźtra ici."; + +/* Settings */ +"Refresh Modules on Launch" = "Actualiser les modules au lancement"; +"Refresh Storage Info" = "Actualiser les infos de stockage"; +"Remember Playback speed" = "MĂ©moriser la vitesse de lecture"; + +/* Actions */ +"Remove" = "Supprimer"; +"Remove All Cache" = "Supprimer tout le cache"; + +/* File Management */ +"Remove All Documents" = "Supprimer tous les documents"; +"Remove Documents" = "Supprimer les documents"; +"Remove Downloaded Media" = "Supprimer les mĂ©dias tĂ©lĂ©chargĂ©s"; +"Remove Downloads" = "Supprimer les tĂ©lĂ©chargements"; +"Remove from Bookmarks" = "Supprimer des favoris"; +"Remove Item" = "Supprimer l'Ă©lĂ©ment"; + +/* Support */ +"Report an Issue" = "Signaler un problĂšme"; + +/* Reset Options */ +"Reset" = "RĂ©initialiser"; +"Reset AniList ID" = "RĂ©initialiser l'ID AniList"; +"Reset Episode Progress" = "RĂ©initialiser la progression de l'Ă©pisode"; +"Reset progress" = "RĂ©initialiser la progression"; +"Reset Progress" = "RĂ©initialiser la progression"; + +/* System */ +"Restart Required" = "RedĂ©marrage requis"; +"Running Sora %@ - cranci1" = "Sora %@ en cours d'exĂ©cution - cranci1"; + +/* Actions */ +"Save" = "Enregistrer"; +"Search" = "Rechercher"; + +/* Search */ +"Search downloads" = "Rechercher dans les tĂ©lĂ©chargements"; +"Search for something..." = "Rechercher quelque chose..."; +"Search..." = "Rechercher..."; + +/* Content */ +"Season %d" = "Saison %d"; +"Season %lld" = "Saison %lld"; +"Segments Color" = "Couleur des segments"; + +/* Modules */ +"Select Module" = "SĂ©lectionner un module"; +"Set Custom AniList ID" = "DĂ©finir un ID AniList personnalisĂ©"; + +/* Interface */ +"Settings" = "ParamĂštres"; +"Shadow" = "Ombre"; +"Show More (%lld more characters)" = "Afficher plus (%lld caractĂšres de plus)"; +"Show PiP Button" = "Afficher le bouton PiP"; +"Show Skip 85s Button" = "Afficher le bouton Sauter 85s"; +"Show Skip Intro / Outro Buttons" = "Afficher les boutons Sauter l'intro / l'outro"; +"Shows" = "SĂ©ries"; +"Size (%@)" = "Taille (%@)"; +"Skip Settings" = "RĂ©glages de saut"; + +/* Player Features */ +"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments." = "Certaines fonctionnalitĂ©s sont limitĂ©es au lecteur Sora et par dĂ©faut, telles que Forcer Paysage, Vitesse par pression longue et les incrĂ©ments de saut de temps personnalisĂ©s."; + +/* App Info */ +"Sora" = "Sora"; +"Sora %@ by cranci1" = "Sora %@ par 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." = "Sora et cranci1 ne sont en aucun cas affiliĂ©s Ă  AniList ou Trakt.\n\nNotez Ă©galement que les mises Ă  jour de progression peuvent ne pas ĂȘtre prĂ©cises Ă  100%."; +"Sora GitHub Repository" = "DĂ©pĂŽt GitHub de Sora"; +"Sora/Sulfur will always remain free with no ADs!" = "Sora/Sulfur restera toujours gratuit et sans publicitĂ©s !"; + +/* Interface */ +"Sort" = "Trier"; +"Speed Settings" = "RĂ©glages de vitesse"; + +/* Playback */ +"Start Watching" = "Commencer Ă  regarder"; +"Start Watching Episode %d" = "Commencer l'Ă©pisode %d"; +"Storage Used" = "Stockage utilisĂ©"; +"Stream" = "Flux"; +"Streaming and video playback." = "Streaming et lecture vidĂ©o."; + +/* Subtitles */ +"Subtitle Color" = "Couleur des sous-titres"; +"Subtitle Settings" = "RĂ©glages des sous-titres"; + +/* Sync */ +"Sync anime progress" = "Synchroniser la progression des animes"; +"Sync TV shows progress" = "Synchroniser la progression des sĂ©ries TV"; + +/* System */ +"System" = "SystĂšme"; + +/* Instructions */ +"Tap a title to override the current match." = "Appuyez sur un titre pour remplacer la correspondance actuelle."; +"Tap Skip" = "Appuyer pour sauter"; +"Tap to manage your modules" = "Appuyez pour gĂ©rer vos modules"; +"Tap to select a module" = "Appuyez pour sĂ©lectionner un module"; + +/* App Information */ +"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." = "Le cache de l'application permet de charger les images plus rapidement.\n\nLa suppression du dossier Documents effacera tous les modules tĂ©lĂ©chargĂ©s.\n\nN'effacez pas les donnĂ©es de l'application Ă  moins de comprendre les consĂ©quences — cela pourrait entraĂźner un dysfonctionnement de l'application."; +"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." = "La plage d'Ă©pisodes contrĂŽle le nombre d'Ă©pisodes qui apparaissent sur chaque page. Les Ă©pisodes sont regroupĂ©s en sĂ©ries (comme 1–25, 26–50, etc.), ce qui vous permet de naviguer plus facilement.\n\nPour les mĂ©tadonnĂ©es d'Ă©pisode, cela fait rĂ©fĂ©rence Ă  la miniature et au titre de l'Ă©pisode, car ils peuvent parfois contenir des spoilers."; +"The module provided only a single episode, this is most likely a movie, so we decided to make separate screens for these cases." = "Le module n'a fourni qu'un seul Ă©pisode, il s'agit trĂšs probablement d'un film, nous avons donc dĂ©cidĂ© de crĂ©er des Ă©crans sĂ©parĂ©s pour ces cas."; + +/* Interface */ +"Thumbnails Width" = "Largeur des miniatures"; +"TMDB Match" = "Correspondance TMDB"; +"Trackers" = "Trackers"; +"Trakt" = "Trakt"; +"Trakt.tv" = "Trakt.tv"; + +/* Search */ +"Try different keywords" = "Essayez diffĂ©rents mots-clĂ©s"; +"Try different search terms" = "Essayez diffĂ©rents termes de recherche"; + +/* Player Controls */ +"Two Finger Hold for Pause" = "Maintenir avec deux doigts pour mettre en pause"; +"Unable to fetch matches. Please try again later." = "Impossible de rĂ©cupĂ©rer les correspondances. Veuillez rĂ©essayer plus tard."; +"Use TMDB Poster Image" = "Utiliser l'image de l'affiche TMDB"; + +/* Version */ +"v%@" = "v%@"; +"Video Player" = "Lecteur vidĂ©o"; + +/* Video Settings */ +"Video Quality Preferences" = "PrĂ©fĂ©rences de qualitĂ© vidĂ©o"; +"View All" = "Voir tout"; +"Watched" = "Vu"; +"Why am I not seeing any episodes?" = "Pourquoi ne vois-je aucun Ă©pisode ?"; +"WiFi Quality" = "QualitĂ© WiFi"; + +/* User Status */ +"You are not logged in" = "Vous n'ĂȘtes pas connectĂ©"; +"You have no items saved." = "Vous n'avez aucun Ă©lĂ©ment enregistrĂ©."; +"Your downloaded episodes will appear here" = "Vos Ă©pisodes tĂ©lĂ©chargĂ©s apparaĂźtront ici"; +"Your recently watched content will appear here" = "Votre contenu rĂ©cemment visionnĂ© apparaĂźtra ici"; + +/* Download Settings */ +"Download Settings" = "ParamĂštres de tĂ©lĂ©chargement"; +"Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources." = "Le nombre maximum de tĂ©lĂ©chargements simultanĂ©s contrĂŽle le nombre d'Ă©pisodes pouvant ĂȘtre tĂ©lĂ©chargĂ©s en mĂȘme temps. Des valeurs plus Ă©levĂ©es peuvent utiliser plus de bande passante et de ressources de l'appareil."; +"Quality" = "QualitĂ©"; +"Max Concurrent Downloads" = "TĂ©lĂ©chargements simultanĂ©s max"; +"Allow Cellular Downloads" = "Autoriser les tĂ©lĂ©chargements cellulaires"; +"Quality Information" = "Informations sur la qualitĂ©"; + +/* Storage */ +"Storage Management" = "Gestion du stockage"; +"Storage Used" = "Stockage utilisĂ©"; +"Library cleared successfully" = "BibliothĂšque vidĂ©e avec succĂšs"; +"All downloads deleted successfully" = "Tous les tĂ©lĂ©chargements ont Ă©tĂ© supprimĂ©s avec succĂšs"; \ No newline at end of file diff --git a/Sora/nl.lproj/Localizable.strings b/Sora/nl.lproj/Localizable.strings new file mode 100644 index 0000000..b14a0d4 --- /dev/null +++ b/Sora/nl.lproj/Localizable.strings @@ -0,0 +1,384 @@ +/* General */ +"About" = "Over"; +"About Sora" = "Over Sora"; +"Active" = "Actief"; +"Active Downloads" = "Actieve Downloads"; +"Actively downloading media can be tracked from here." = "Actief downloaden van media kan hier worden gevolgd."; +"Add Module" = "Module Toevoegen"; +"Adjust the number of media items per row in portrait and landscape modes." = "Pas het aantal media-items per rij aan in staande en liggende modus."; +"Advanced" = "Geavanceerd"; +"AKA Sulfur" = "AKA Sulfur"; +"All Bookmarks" = "Alle Bladwijzers"; +"All Prev" = "Alle vorige"; +"All Watching" = "Alles Wat Ik Kijk"; +"AniList" = "AniList"; +"Anonymous data is collected to improve the app. No personal information is collected. This can be disabled at any time." = "Anonieme gegevens worden verzameld om de app te verbeteren. Er worden geen persoonlijke gegevens verzameld. Dit kan op elk moment worden uitgeschakeld."; +"App Info" = "App Info"; +"App Language" = "App Taal"; +"App Storage" = "App Opslag"; +"Appearance" = "Uiterlijk"; + +/* Alerts and Actions */ +"Are you sure you want to clear all cached data? This will help free up storage space." = "Weet je zeker dat je alle gecachte gegevens wilt wissen? Dit helpt opslagruimte vrij te maken."; +"Are you sure you want to delete '%@'?" = "Weet je zeker dat je '%@' wilt verwijderen?"; +"Are you sure you want to delete all %1$lld episodes in '%2$@'?" = "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." = "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." = "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)?" = "Weet je zeker dat je alle gedownloade mediabestanden (.mov, .mp4, .pkg) wilt verwijderen?"; +"Are you sure you want to remove all files in the Documents folder?" = "Weet je zeker dat je alle bestanden in de map Documenten wilt verwijderen?"; + +/* Features */ +"Author" = "Auteur"; +"Background Enabled" = "Achtergrond Ingeschakeld"; +"Bookmark items for an easier access later." = "Bladwijzer items voor eenvoudigere toegang later."; +"Bookmarks" = "Bladwijzers"; +"Bottom Padding" = "Onderste Padding"; +"Cancel" = "Annuleren"; +"Cellular Quality" = "Mobiele Kwaliteit"; +"Check out some community modules here!" = "Bekijk hier enkele community modules!"; +"Choose preferred video resolution for WiFi and cellular connections." = "Kies de gewenste videoresolutie voor WiFi en mobiele verbindingen."; +"Clear" = "Wissen"; +"Clear All Downloads" = "Alle Downloads Wissen"; +"Clear Cache" = "Wis Cache"; +"Clear Library Only" = "Alleen Bibliotheek Wissen"; +"Clear Logs" = "Wis Logs"; +"Click the plus button to add a module!" = "Klik op de plus-knop om een module toe te voegen!"; +"Continue Watching" = "Verder Kijken"; +"Continue Watching Episode %d" = "Verder Kijken Aflevering %d"; +"Contributors" = "Bijdragers"; +"Copied to Clipboard" = "Gekopieerd naar Klembord"; +"Copy to Clipboard" = "KopiĂ«ren naar Klembord"; +"Copy URL" = "Kopieer URL"; + +/* Episodes */ +"%lld Episodes" = "%lld Afleveringen"; +"%lld of %lld" = "%1$lld van %2$lld"; +"%lld-%lld" = "%1$lld-%2$lld"; +"%lld%% seen" = "%lld%% gezien"; +"Episode %lld" = "Aflevering %lld"; +"Episodes" = "Afleveringen"; +"Episodes might not be available yet or there could be an issue with the source." = "Afleveringen zijn mogelijk nog niet beschikbaar of er is een probleem met de bron."; +"Episodes Range" = "Afleveringen Bereik"; + +/* System */ +"cranci1" = "cranci1"; +"Dark" = "Donker"; +"DATA & LOGS" = "DATA & LOGS"; +"Debug" = "Debug"; +"Debugging and troubleshooting." = "Debuggen en probleemoplossing."; + +/* Actions */ +"Delete" = "Verwijderen"; +"Delete All" = "Alles Wissen"; +"Delete All Downloads" = "Alle Downloads Verwijderen"; +"Delete All Episodes" = "Alle Afleveringen Wissen"; +"Delete Download" = "Downloads Wissen"; +"Delete Episode" = "Afleveringen Wissen"; + +/* Player */ +"Double Tap to Seek" = "Dubbel Tikken om te Zoeken"; +"Double tapping the screen on it's sides will skip with the short tap setting." = "Dubbel tikken op de zijkanten van het scherm zal overslaan met de korte tik instelling."; + +/* Downloads */ +"Download" = "Downloaden"; +"Download Episode" = "Aflevering Downloaden"; +"Download Summary" = "Download Samenvatting"; +"Download This Episode" = "Download Deze Aflevering"; +"Downloaded" = "Gedownload"; +"Downloaded Shows" = "Gedownloade Series"; +"Downloading" = "Downloaden"; +"Downloads" = "Downloads"; + +/* Settings */ +"Enable Analytics" = "Analytics Inschakelen"; +"Enable Subtitles" = "Ondertiteling Inschakelen"; + +/* Data Management */ +"Erase" = "Verwijden"; +"Erase all App Data" = "Wis Alle App Data"; +"Erase App Data" = "Verwijder App Data"; + +/* Errors */ +"Error" = "Fout"; +"Error Fetching Results" = "Fout bij Ophalen Resultaten"; +"Errors and critical issues." = "Fouten en kritieke problemen."; +"Failed to load contributors" = "Laden van bijdragers mislukt"; + +/* Features */ +"Fetch Episode metadata" = "Haal Aflevering Metadata op"; +"Files Downloaded" = "Gedownloade Bestanden"; +"Font Size" = "Lettergrootte"; + +/* Interface */ +"Force Landscape" = "Forceer Landschap"; +"General" = "Algemeen"; +"General events and activities." = "Algemene gebeurtenissen en activiteiten."; +"General Preferences" = "Algemene Voorkeuren"; +"Hide Splash Screen" = "Splash Screen Verbergen"; +"HLS video downloading." = "HLS video downloaden."; +"Hold Speed" = "Vasthouden Snelheid"; + +/* Info */ +"Info" = "Info"; +"INFOS" = "INFO"; +"Installed Modules" = "GeĂŻnstalleerde Modules"; +"Interface" = "Interface"; + +/* Social */ +"Join the Discord" = "Word lid van de Discord"; + +/* Layout */ +"Landscape Columns" = "Liggende Kolommen"; +"Language" = "Taal"; +"LESS" = "MINDER"; + +/* Library */ +"Library" = "Bibliotheek"; +"License (GPLv3.0)" = "Licentie (GPLv3.0)"; +"Light" = "Licht"; + +/* Loading States */ +"Loading Episode %lld..." = "Aflevering %lld laden..."; +"Loading logs..." = "Logboeken laden..."; +"Loading module information..." = "Module-informatie laden..."; +"Loading Stream" = "Stream Laden"; + +/* Logging */ +"Log Debug Info" = "Debug Info Loggen"; +"Log Filters" = "Log Filters"; +"Log In with AniList" = "Inloggen met AniList"; +"Log In with Trakt" = "Inloggen met Trakt"; +"Log Out from AniList" = "Uitloggen van AniList"; +"Log Out from Trakt" = "Uitloggen van Trakt"; +"Log Types" = "Logboek Types"; +"Logged in as" = "Ingelogd als"; +"Logged in as " = "Ingelogd als "; + +/* Logs and Settings */ +"Logs" = "Logboeken"; +"Long press Skip" = "Lang Drukken Overslaan"; +"MAIN" = "Hoofdinstellingen"; +"Main Developer" = "Hoofdontwikkelaar"; +"MAIN SETTINGS" = "HOOFDINSTELLINGEN"; + +/* Media Actions */ +"Mark All Previous Watched" = "Markeer alles als gezien"; +"Mark as Watched" = "Markeer als gezien"; +"Mark Episode as Watched" = "Markeer aflevering als gezien"; +"Mark Previous Episodes as Watched" = "Markeer vorige afleveringen als gezien"; +"Mark watched" = "Markeer als gezien"; +"Match with AniList" = "Match met AniList"; +"Match with TMDB" = "Match met TMDB"; +"Matched ID: %lld" = "Gematchte ID: %lld"; +"Matched with: %@" = "Match met: %@"; +"Max Concurrent Downloads" = "Maximaal gelijktijdige downloads"; + +/* Media Interface */ +"Media Grid Layout" = "Media Raster Layout"; +"Media Player" = "Media Speler"; +"Media View" = "Mediaweergave"; +"Metadata Provider" = "Metadata Provider"; +"Metadata Providers Order" = "Metadata Providers Volgorde"; +"Module Removed" = "Module Verwijderd"; +"Modules" = "Modules"; + +/* Headers */ +"MODULES" = "MODULES"; +"MORE" = "MEER"; + +/* Status Messages */ +"No Active Downloads" = "Geen Actieve Downloads"; +"No AniList matches found" = "Geen AniList overeenkomsten gevonden"; +"No Data Available" = "Geen Gegevens Beschikbaar"; +"No Downloads" = "Geen Downloads"; +"No episodes available" = "Geen afleveringen beschikbaar"; +"No Episodes Available" = "Geen Afleveringen Beschikbaar"; +"No items to continue watching." = "Geen items om verder te kijken."; +"No matches found" = "Geen overeenkomsten gevonden"; +"No Module Selected" = "Geen Module Geselecteerd"; +"No Modules" = "Geen Modules"; +"No Results Found" = "Geen Resultaten Gevonden"; +"No Search Results Found" = "Geen Zoekresultaten Gevonden"; +"Nothing to Continue Watching" = "Niets om Verder te Kijken"; + +/* Notes and Messages */ +"Note that the modules will be replaced only if there is a different version string inside the JSON file." = "Let op: de modules worden alleen vervangen als er een andere versiestring in het JSON-bestand staat."; + +/* Actions */ +"OK" = "OK"; +"Open Community Library" = "Open Community Bibliotheek"; + +/* External Services */ +"Open in AniList" = "Openen in AniList"; +"Original Poster" = "Originele Poster"; + +/* Playback */ +"Paused" = "Gepauzeerd"; +"Play" = "Afspelen"; +"Player" = "Speler"; + +/* System Messages */ +"Please restart the app to apply the language change." = "Herstart de app om de taalwijziging toe te passen."; +"Please select a module from settings" = "Selecteer een module uit de instellingen"; + +/* Interface */ +"Portrait Columns" = "Staande Kolommen"; +"Progress bar Marker Color" = "Voortgangsbalk Markeerkleur"; +"Provider: %@" = "Provider: %@"; + +/* Queue */ +"Queue" = "Wachtrij"; +"Queued" = "In Wachtrij"; + +/* Content */ +"Recently watched content will appear here." = "Recent bekeken inhoud verschijnt hier."; + +/* Settings */ +"Refresh Modules on Launch" = "Ververs Modules bij Opstarten"; +"Refresh Storage Info" = "Opslaginformatie Vernieuwen"; +"Remember Playback speed" = "Onthoud Afspeelsnelheid"; + +/* Actions */ +"Remove" = "Verwijderen"; +"Remove All Cache" = "Verwijder Alle Cache"; + +/* File Management */ +"Remove All Documents" = "Verwijder Alle Documenten"; +"Remove Documents" = "Documenten Verwijderen"; +"Remove Downloaded Media" = "Gedownloade Media Verwijderen"; +"Remove Downloads" = "Verwijder Downloads"; +"Remove from Bookmarks" = "Verwijderen uit Bladwijzers"; +"Remove Item" = "Item Verwijderen"; + +/* Support */ +"Report an Issue" = "Rapporteer een Probleem"; + +/* Reset Options */ +"Reset" = "Resetten"; +"Reset AniList ID" = "AniList ID Resetten"; +"Reset Episode Progress" = "Afleveringsvoortgang Resetten"; +"Reset progress" = "Voortgang resetten"; +"Reset Progress" = "Voortgang Resetten"; + +/* System */ +"Restart Required" = "Herstart Vereist"; +"Running Sora %@ - cranci1" = "Sora %@ draait - cranci1"; + +/* Actions */ +"Save" = "Opslaan"; +"Search" = "Zoeken"; + +/* Search */ +"Search downloads" = "Downloads zoeken"; +"Search for something..." = "Zoek naar iets..."; +"Search..." = "Zoeken..."; + +/* Content */ +"Season %d" = "Seizoen %d"; +"Season %lld" = "Seizoen %lld"; +"Segments Color" = "Segmenten Kleur"; + +/* Modules */ +"Select Module" = "Module Selecteren"; +"Set Custom AniList ID" = "Aangepaste AniList ID Instellen"; + +/* Interface */ +"Settings" = "Instellingen"; +"Shadow" = "Schaduw"; +"Show More (%lld more characters)" = "Meer Tonen (%lld meer tekens)"; +"Show PiP Button" = "Toon PiP Knop"; +"Show Skip 85s Button" = "Toon Overslaan 85s Knop"; +"Show Skip Intro / Outro Buttons" = "Toon Overslaan Intro / Outro Knoppen"; +"Shows" = "Series"; +"Size (%@)" = "Grootte (%@)"; +"Skip Settings" = "Overslaan Instellingen"; + +/* Player Features */ +"Some features are limited to the Sora and Default player, such as ForceLandscape, holdSpeed and custom time skip increments." = "Sommige functies zijn beperkt tot de Sora en Standaard speler, zoals ForceLandscape, holdSpeed en aangepaste tijd overslaan stappen."; + +/* App Info */ +"Sora" = "Sora"; +"Sora %@ by cranci1" = "Sora %@ door 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." = "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" = "Sora GitHub Repository"; +"Sora/Sulfur will always remain free with no ADs!" = "Sora/Sulfur blijft altijd gratis zonder advertenties!"; + +/* Interface */ +"Sort" = "Sorteren"; +"Speed Settings" = "Snelheidsinstellingen"; + +/* Playback */ +"Start Watching" = "Start met Kijken"; +"Start Watching Episode %d" = "Start met Kijken Aflevering %d"; +"Storage Used" = "Gebruikte Opslag"; +"Stream" = "Stream"; +"Streaming and video playback." = "Streaming en video afspelen."; + +/* Subtitles */ +"Subtitle Color" = "Ondertitelingskleur"; +"Subtitle Settings" = "Ondertitelingsinstellingen"; + +/* Sync */ +"Sync anime progress" = "Synchroniseer anime voortgang"; +"Sync TV shows progress" = "Synchroniseer TV series voortgang"; + +/* System */ +"System" = "Systeem"; + +/* Instructions */ +"Tap a title to override the current match." = "Tik op een titel om de huidige match te overschrijven."; +"Tap Skip" = "Tik Overslaan"; +"Tap to manage your modules" = "Tik om je modules te beheren"; +"Tap to select a module" = "Tik om een module te selecteren"; + +/* App Information */ +"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." = "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." = "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." = "De module heeft slechts één aflevering geleverd, dit is waarschijnlijk een film, daarom hebben we aparte schermen gemaakt voor deze gevallen."; + +/* Interface */ +"Thumbnails Width" = "Miniatuur Breedte"; +"TMDB Match" = "TMDB Match"; +"Trackers" = "Trackers"; +"Trakt" = "Trakt"; +"Trakt.tv" = "Trakt.tv"; + +/* Search */ +"Try different keywords" = "Probeer andere zoekwoorden"; +"Try different search terms" = "Probeer andere zoektermen"; + +/* Player Controls */ +"Two Finger Hold for Pause" = "Twee Vingers Vasthouden voor Pauze"; +"Unable to fetch matches. Please try again later." = "Kan geen matches ophalen. Probeer het later opnieuw."; +"Use TMDB Poster Image" = "TMDB Poster Afbeelding Gebruiken"; + +/* Version */ +"v%@" = "v%@"; +"Video Player" = "Videospeler"; + +/* Video Settings */ +"Video Quality Preferences" = "Video Kwaliteit Voorkeuren"; +"View All" = "Alles Bekijken"; +"Watched" = "Bekeken"; +"Why am I not seeing any episodes?" = "Waarom zie ik geen afleveringen?"; +"WiFi Quality" = "WiFi Kwaliteit"; + +/* User Status */ +"You are not logged in" = "Je bent niet ingelogd"; +"You have no items saved." = "Je hebt geen items opgeslagen."; +"Your downloaded episodes will appear here" = "Je gedownloade afleveringen verschijnen hier"; +"Your recently watched content will appear here" = "Je recent bekeken inhoud verschijnt hier"; + +/* Download Settings */ +"Download Settings" = "Download Instellingen"; +"Max concurrent downloads controls how many episodes can download simultaneously. Higher values may use more bandwidth and device resources." = "Maximum gelijktijdige downloads bepaalt hoeveel afleveringen tegelijk kunnen worden gedownload. Hogere waarden kunnen meer bandbreedte en apparaatbronnen gebruiken."; +"Quality" = "Kwaliteit"; +"Max Concurrent Downloads" = "Maximum Gelijktijdige Downloads"; +"Allow Cellular Downloads" = "Downloads via Mobiel Netwerk Toestaan"; +"Quality Information" = "Kwaliteitsinformatie"; + +/* Storage */ +"Storage Management" = "Opslagbeheer"; +"Storage Used" = "Gebruikte Opslag"; +"Library cleared successfully" = "Bibliotheek succesvol gewist"; +"All downloads deleted successfully" = "Alle downloads succesvol verwijderd"; \ No newline at end of file diff --git a/Sulfur.xcodeproj/project.pbxproj b/Sulfur.xcodeproj/project.pbxproj index ba5a9f6..5f2a3d8 100644 --- a/Sulfur.xcodeproj/project.pbxproj +++ b/Sulfur.xcodeproj/project.pbxproj @@ -17,6 +17,10 @@ 0457C59E2DE78267000AFBD9 /* BookmarkLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C59B2DE78267000AFBD9 /* BookmarkLink.swift */; }; 0457C59F2DE78267000AFBD9 /* BookmarkGridItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C5992DE78267000AFBD9 /* BookmarkGridItemView.swift */; }; 0457C5A12DE78385000AFBD9 /* BookmarksDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0457C5A02DE78385000AFBD9 /* BookmarksDetailView.swift */; }; + 0488FA952DFDE724007575E1 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0488FA902DFDE724007575E1 /* Localizable.strings */; }; + 0488FA962DFDE724007575E1 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0488FA932DFDE724007575E1 /* Localizable.strings */; }; + 0488FA9A2DFDF380007575E1 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0488FA982DFDF380007575E1 /* Localizable.strings */; }; + 0488FA9E2DFDF3BB007575E1 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0488FA9C2DFDF3BB007575E1 /* Localizable.strings */; }; 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 */; }; @@ -36,7 +40,9 @@ 132AF1252D9995F900A0140B /* JSController-Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 132AF1242D9995F900A0140B /* JSController-Search.swift */; }; 13367ECC2DF70698009CB33F /* Nuke in Frameworks */ = {isa = PBXBuildFile; productRef = 13367ECB2DF70698009CB33F /* Nuke */; }; 13367ECE2DF70698009CB33F /* NukeUI in Frameworks */ = {isa = PBXBuildFile; productRef = 13367ECD2DF70698009CB33F /* NukeUI */; }; - 13367ED02DF70819009CB33F /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 13367ECF2DF70819009CB33F /* Localizable.xcstrings */; }; + 133CF6A62DFEBE9000BD13F9 /* VideoWatchingActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133CF6A32DFEBE8F00BD13F9 /* VideoWatchingActivity.swift */; }; + 133CF6A72DFEBE9000BD13F9 /* SharePlayManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133CF6A42DFEBE8F00BD13F9 /* SharePlayManager.swift */; }; + 133CF6A82DFEBE9000BD13F9 /* SharePlayCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133CF6A52DFEBE9000BD13F9 /* SharePlayCoordinator.swift */; }; 133D7C6E2D2BE2500075467E /* SoraApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6D2D2BE2500075467E /* SoraApp.swift */; }; 133D7C702D2BE2500075467E /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 133D7C6F2D2BE2500075467E /* ContentView.swift */; }; 133D7C722D2BE2520075467E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 133D7C712D2BE2520075467E /* Assets.xcassets */; }; @@ -112,6 +118,10 @@ 0457C59A2DE78267000AFBD9 /* BookmarkGridView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkGridView.swift; sourceTree = ""; }; 0457C59B2DE78267000AFBD9 /* BookmarkLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarkLink.swift; sourceTree = ""; }; 0457C5A02DE78385000AFBD9 /* BookmarksDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookmarksDetailView.swift; sourceTree = ""; }; + 0488FA8F2DFDE724007575E1 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = Localizable.strings; sourceTree = ""; }; + 0488FA922DFDE724007575E1 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = Localizable.strings; sourceTree = ""; }; + 0488FA992DFDF380007575E1 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = Localizable.strings; sourceTree = ""; }; + 0488FA9D2DFDF3BB007575E1 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = Localizable.strings; 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 = ""; }; @@ -130,7 +140,9 @@ 132AF1202D99951700A0140B /* JSController-Streams.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Streams.swift"; sourceTree = ""; }; 132AF1222D9995C300A0140B /* JSController-Details.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Details.swift"; sourceTree = ""; }; 132AF1242D9995F900A0140B /* JSController-Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSController-Search.swift"; sourceTree = ""; }; - 13367ECF2DF70819009CB33F /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + 133CF6A32DFEBE8F00BD13F9 /* VideoWatchingActivity.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoWatchingActivity.swift; sourceTree = ""; }; + 133CF6A42DFEBE8F00BD13F9 /* SharePlayManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SharePlayManager.swift; sourceTree = ""; }; + 133CF6A52DFEBE9000BD13F9 /* SharePlayCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SharePlayCoordinator.swift; sourceTree = ""; }; 133D7C6A2D2BE2500075467E /* Sulfur.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sulfur.app; sourceTree = BUILT_PRODUCTS_DIR; }; 133D7C6D2D2BE2500075467E /* SoraApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoraApp.swift; sourceTree = ""; }; 133D7C6F2D2BE2500075467E /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -241,6 +253,38 @@ path = BookmarkComponents; sourceTree = ""; }; + 0488FA912DFDE724007575E1 /* en.lproj */ = { + isa = PBXGroup; + children = ( + 0488FA902DFDE724007575E1 /* Localizable.strings */, + ); + path = en.lproj; + sourceTree = ""; + }; + 0488FA942DFDE724007575E1 /* nl.lproj */ = { + isa = PBXGroup; + children = ( + 0488FA932DFDE724007575E1 /* Localizable.strings */, + ); + path = nl.lproj; + sourceTree = ""; + }; + 0488FA972DFDF334007575E1 /* fr.lproj */ = { + isa = PBXGroup; + children = ( + 0488FA982DFDF380007575E1 /* Localizable.strings */, + ); + path = fr.lproj; + sourceTree = ""; + }; + 0488FA9B2DFDF385007575E1 /* ar.lproj */ = { + isa = PBXGroup; + children = ( + 0488FA9C2DFDF3BB007575E1 /* Localizable.strings */, + ); + path = ar.lproj; + sourceTree = ""; + }; 04F08EDA2DE10BE3006B29D9 /* ProgressiveBlurView */ = { isa = PBXGroup; children = ( @@ -311,6 +355,27 @@ path = Analytics; sourceTree = ""; }; + 133CF6A22DFEBE8100BD13F9 /* SharePlay */ = { + isa = PBXGroup; + children = ( + 133CF6A42DFEBE8F00BD13F9 /* SharePlayManager.swift */, + 133CF6A52DFEBE9000BD13F9 /* SharePlayCoordinator.swift */, + 133CF6A32DFEBE8F00BD13F9 /* VideoWatchingActivity.swift */, + ); + path = SharePlay; + sourceTree = ""; + }; + 133CF6A92DFEBEAB00BD13F9 /* MediaUtils */ = { + isa = PBXGroup; + children = ( + 133CF6A22DFEBE8100BD13F9 /* SharePlay */, + 13DC0C442D302C6A00D0F966 /* NormalPlayer */, + 13EA2BD02D32D97400C1EBD7 /* CustomPlayer */, + 13C0E5E82D5F85DD00E7F619 /* ContinueWatching */, + ); + path = MediaUtils; + sourceTree = ""; + }; 133D7C612D2BE2500075467E = { isa = PBXGroup; children = ( @@ -330,16 +395,20 @@ 133D7C6C2D2BE2500075467E /* Sora */ = { isa = PBXGroup; children = ( + 0488FA942DFDE724007575E1 /* nl.lproj */, + 0488FA912DFDE724007575E1 /* en.lproj */, + 0488FA972DFDF334007575E1 /* fr.lproj */, + 0488FA9B2DFDF385007575E1 /* ar.lproj */, 130C6BF82D53A4C200DC1432 /* Sora.entitlements */, 13DC0C412D2EC9BA00D0F966 /* Info.plist */, 13103E802D589D6C000F0673 /* Tracking Services */, - 133D7C852D2BE2640075467E /* Utils */, + 133CF6A92DFEBEAB00BD13F9 /* MediaUtils */, 133D7C7B2D2BE2630075467E /* Views */, + 133D7C852D2BE2640075467E /* Utils */, 133D7C6D2D2BE2500075467E /* SoraApp.swift */, 133D7C6F2D2BE2500075467E /* ContentView.swift */, 133D7C712D2BE2520075467E /* Assets.xcassets */, 133D7C732D2BE2520075467E /* Preview Content */, - 13367ECF2DF70819009CB33F /* Localizable.xcstrings */, ); path = Sora; sourceTree = ""; @@ -405,10 +474,8 @@ 133D7C8A2D2BE2640075467E /* JSLoader */, 1327FBA52D758CEA00FC6689 /* Analytics */, 133D7C862D2BE2640075467E /* Extensions */, - 13DC0C442D302C6A00D0F966 /* MediaPlayer */, 13103E8C2D58E037000F0673 /* SkeletonCells */, 72443C832DC8046500A61321 /* DownloadUtils */, - 13C0E5E82D5F85DD00E7F619 /* ContinueWatching */, ); path = Utils; sourceTree = ""; @@ -552,14 +619,13 @@ path = Auth; sourceTree = ""; }; - 13DC0C442D302C6A00D0F966 /* MediaPlayer */ = { + 13DC0C442D302C6A00D0F966 /* NormalPlayer */ = { isa = PBXGroup; children = ( - 13EA2BD02D32D97400C1EBD7 /* CustomPlayer */, 13DC0C452D302C7500D0F966 /* VideoPlayer.swift */, 13EA2BD82D32D98400C1EBD7 /* NormalPlayer.swift */, ); - path = MediaPlayer; + path = NormalPlayer; sourceTree = ""; }; 13E62FBF2DABC3A20007E259 /* Trakt */ = { @@ -676,6 +742,9 @@ knownRegions = ( en, Base, + nl, + fr, + ar, ); mainGroup = 133D7C612D2BE2500075467E; packageReferences = ( @@ -698,9 +767,12 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0488FA9A2DFDF380007575E1 /* Localizable.strings in Resources */, + 0488FA9E2DFDF3BB007575E1 /* Localizable.strings in Resources */, 133D7C752D2BE2520075467E /* Preview Assets.xcassets in Resources */, 133D7C722D2BE2520075467E /* Assets.xcassets in Resources */, - 13367ED02DF70819009CB33F /* Localizable.xcstrings in Resources */, + 0488FA952DFDE724007575E1 /* Localizable.strings in Resources */, + 0488FA962DFDE724007575E1 /* Localizable.strings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -759,6 +831,7 @@ 138AA1B82D2D66FD0021F9DF /* EpisodeCell.swift in Sources */, 722248662DCBC13E00CABE2D /* JSController-Downloads.swift in Sources */, 133D7C8C2D2BE2640075467E /* SearchView.swift in Sources */, + 133CF6A62DFEBE9000BD13F9 /* VideoWatchingActivity.swift in Sources */, 13DB468E2D90093A008CBC03 /* Anilist-Token.swift in Sources */, 1EAC7A322D888BC50083984D /* MusicProgressSlider.swift in Sources */, 722248632DCBAA4700CABE2D /* JSController-HeaderManager.swift in Sources */, @@ -766,6 +839,7 @@ 133D7C922D2BE2640075467E /* URLSession.swift in Sources */, 0457C5A12DE78385000AFBD9 /* BookmarksDetailView.swift in Sources */, 133D7C912D2BE2640075467E /* SettingsViewModule.swift in Sources */, + 133CF6A82DFEBE9000BD13F9 /* SharePlayCoordinator.swift in Sources */, 13E62FC22DABC5830007E259 /* Trakt-Login.swift in Sources */, 133F55BB2D33B55100E08EEA /* LibraryManager.swift in Sources */, 13E62FC42DABC58C0007E259 /* Trakt-Token.swift in Sources */, @@ -793,6 +867,7 @@ 13C0E5EA2D5F85EA00E7F619 /* ContinueWatchingManager.swift in Sources */, 13637B8A2DE0EA1100BDA2FC /* UserDefaults.swift in Sources */, 0457C59D2DE78267000AFBD9 /* BookmarkGridView.swift in Sources */, + 133CF6A72DFEBE9000BD13F9 /* SharePlayManager.swift in Sources */, 0457C59E2DE78267000AFBD9 /* BookmarkLink.swift in Sources */, 0457C59F2DE78267000AFBD9 /* BookmarkGridItemView.swift in Sources */, ); @@ -800,6 +875,41 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXVariantGroup section */ + 0488FA902DFDE724007575E1 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 0488FA8F2DFDE724007575E1 /* en */, + ); + name = Localizable.strings; + sourceTree = ""; + }; + 0488FA932DFDE724007575E1 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 0488FA922DFDE724007575E1 /* nl */, + ); + name = Localizable.strings; + sourceTree = ""; + }; + 0488FA982DFDF380007575E1 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 0488FA992DFDF380007575E1 /* fr */, + ); + name = Localizable.strings; + sourceTree = ""; + }; + 0488FA9C2DFDF3BB007575E1 /* Localizable.strings */ = { + isa = PBXVariantGroup; + children = ( + 0488FA9D2DFDF3BB007575E1 /* ar */, + ); + name = Localizable.strings; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + /* Begin XCBuildConfiguration section */ 133D7C762D2BE2520075467E /* Debug */ = { isa = XCBuildConfiguration;