diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index b24d42ac75..4271eeae82 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -90,6 +90,11 @@ AFCE353727E4ED7B00FEA6C2 /* NCShareCells.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFCE353627E4ED7B00FEA6C2 /* NCShareCells.swift */; }; AFCE353927E5DE0500FEA6C2 /* Shareable.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFCE353827E5DE0400FEA6C2 /* Shareable.swift */; }; CB3666201AF7550816B5CD6A /* NCContextMenuComment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8932E90EC4278026D86CCCC9 /* NCContextMenuComment.swift */; }; + AFCE353927E5DE0500FEA6C2 /* NCShare+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFCE353827E5DE0400FEA6C2 /* NCShare+Helper.swift */; }; + B5C980182DACD51C0041B146 /* NCMediaCommandView.xib in Resources */ = {isa = PBXBuildFile; fileRef = B5C980172DACD51C0041B146 /* NCMediaCommandView.xib */; }; + B5C9801A2DACD56C0041B146 /* NCMedia+Menu.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5C980192DACD56C0041B146 /* NCMedia+Menu.swift */; }; + C04E2F232A17BB4D001BAD85 /* FilesIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C04E2F222A17BB4D001BAD85 /* FilesIntegrationTests.swift */; }; + D575039F27146F93008DC9DC /* String+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A0D1342591FBC5008F8A13 /* String+Extension.swift */; }; D5B6AA7827200C7200D49C24 /* NCActivityTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5B6AA7727200C7200D49C24 /* NCActivityTableViewCell.swift */; }; F310B1EF2BA862F1001C42F5 /* NCViewerMedia+VisionKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = F310B1EE2BA862F1001C42F5 /* NCViewerMedia+VisionKit.swift */; }; F31165022F9674A1009A1E37 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = F31165012F9674A1009A1E37 /* AppIcon.icon */; }; @@ -1280,6 +1285,9 @@ AFCE353827E5DE0400FEA6C2 /* Shareable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shareable.swift; sourceTree = ""; }; B4C7A5B36D1ED178FB6B76CB /* NCContextMenuPlayerTracks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCContextMenuPlayerTracks.swift; sourceTree = ""; }; BB7697C94BA14450A0867940 /* NCContextMenuProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCContextMenuProfile.swift; sourceTree = ""; }; + AFCE353827E5DE0400FEA6C2 /* NCShare+Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCShare+Helper.swift"; sourceTree = ""; }; + B5C980172DACD51C0041B146 /* NCMediaCommandView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = NCMediaCommandView.xib; sourceTree = ""; }; + B5C980192DACD56C0041B146 /* NCMedia+Menu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCMedia+Menu.swift"; sourceTree = ""; }; C0046CDA2A17B98400D87C9D /* NextcloudUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NextcloudUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C04E2F202A17BB4D001BAD85 /* NextcloudIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NextcloudIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D5B6AA7727200C7200D49C24 /* NCActivityTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCActivityTableViewCell.swift; sourceTree = ""; }; @@ -2074,6 +2082,18 @@ AF93471127E2341B002537EE /* NCContextMenuShare.swift */, F75D19E225EFE09000D74598 /* NCContextMenuTrash.swift */, F710D2012405826100A6033D /* NCContextMenuViewer.swift */, + F77A697C250A0FBC00FF1708 /* NCCollectionViewCommon+Menu.swift */, + F78C6FDD296D677300C952C3 /* NCContextMenu.swift */, + B5C980192DACD56C0041B146 /* NCMedia+Menu.swift */, + 3704EB2923D5A58400455C5B /* NCMenu.storyboard */, + 371B5A2D23D0B04500FAFAE9 /* NCMenu.swift */, + AF935066276B84E700BD078F /* NCMenu+FloatingPanel.swift */, + AF68326927BE65A90010BF0B /* NCMenuAction.swift */, + F7FAFD3928BFA947000777FE /* NCNotification+Menu.swift */, + AF93471127E2341B002537EE /* NCShare+Menu.swift */, + F75D19E225EFE09000D74598 /* NCTrash+Menu.swift */, + F710D2012405826100A6033D /* NCViewerContextMenu.swift */, + 8491B1CC273BBA82001C8C5B /* UIViewController+Menu.swift */, ); path = Menu; sourceTree = ""; @@ -3246,6 +3266,7 @@ F7EC9CB921185F2000F1C5CE /* Media */ = { isa = PBXGroup; children = ( + B5C980172DACD51C0041B146 /* NCMediaCommandView.xib */, F720B5B72507B9A5008C94E5 /* Cell */, F7501C302212E57400FB1415 /* NCMedia.storyboard */, F7501C312212E57400FB1415 /* NCMedia.swift */, @@ -4079,6 +4100,7 @@ F74C0437253F1CDC009762AB /* NCShares.storyboard in Resources */, F7F4F10C27ECDBDB008676F9 /* Inconsolata-Regular.ttf in Resources */, F7B8B83025681C3400967775 /* GoogleService-Info.plist in Resources */, + B5C980182DACD51C0041B146 /* NCMediaCommandView.xib in Resources */, F7381EE5218218C9000B1560 /* NCOffline.storyboard in Resources */, F768822D2C0DD1E7001CF441 /* Acknowledgements.rtf in Resources */, F76D3CF32428B94E005DFA87 /* NCViewerPDFSearchCell.xib in Resources */, @@ -4914,6 +4936,7 @@ F758B45E212C569D00515F55 /* NCScanCell.swift in Sources */, F78B87E72B62527100C65ADC /* NCMediaDataSource.swift in Sources */, F76882272C0DD1E7001CF441 /* NCManageE2EEView.swift in Sources */, + B5C9801A2DACD56C0041B146 /* NCMedia+Menu.swift in Sources */, F7864ACC2A78FE73004870E0 /* NCManageDatabase+LocalFile.swift in Sources */, F7327E302B73A86700A462C7 /* NCNetworking+WebDAV.swift in Sources */, F3DDFE1E2F1F8EC600A784C8 /* ChatInputField.swift in Sources */, diff --git a/iOSClient/AppDelegate.swift b/iOSClient/AppDelegate.swift index 133431af35..8ceda5f386 100644 --- a/iOSClient/AppDelegate.swift +++ b/iOSClient/AppDelegate.swift @@ -16,6 +16,11 @@ import RealmSwift class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate { var backgroundSessionCompletionHandler: (() -> Void)? + var activeLogin: NCLogin? + var activeLoginWeb: NCLoginWeb? + var taskAutoUploadDate: Date = Date() + var orientationLock = UIInterfaceOrientationMask.all + @objc let adjust = AdjustHelper() var isUiTestingEnabled: Bool { return ProcessInfo.processInfo.arguments.contains("UI_TESTING") } @@ -30,7 +35,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD var bgTask: UIBackgroundTaskIdentifier = .invalid var pushSubscriptionTask: Task? - + var window: UIWindow? + var sceneIdentifier: String = "" + var activeViewController: UIViewController? + var account: String = "" + var urlBase: String = "" + var user: String = "" + var userId: String = "" + var password: String = "" + var timerErrorNetworking: Timer? + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { if isUiTestingEnabled { Task { @@ -44,6 +58,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD utilityFileSystem.emptyTemporaryDirectory() utilityFileSystem.clearCacheDirectory("com.limit-point.LivePhoto") + UINavigationBar.appearance().tintColor = NCBrandColor.shared.brand + UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = NCBrandColor.shared.brand + let versionNextcloudiOS = String(format: NCBrandOptions.shared.textCopyrightNextcloudiOS, utility.getVersionBuild()) NCAppVersionManager.shared.checkAndUpdateInstallState() @@ -114,6 +131,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD if NCBrandOptions.shared.enforce_passcode_lock { NCPreferences().requestPasscodeAtStart = true } + +// if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene { +// for window in windowScene.windows { +// let imageViews = window.allImageViews() +// // Do something with the imageViews +// for imageView in imageViews { +// print("Found image view: \(imageView)") +// imageView.tintColor = UITraitCollection.current.userInterfaceStyle == .dark ? .white : .black +// } +// } +// } return true } @@ -145,6 +173,203 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD // Use this method to release any resources that were specific to the discarded scenes, as they will not return. } + // MARK: - Background Task + + /* + @discussion Schedule a refresh task request to ask that the system launch your app briefly so that you can download data and keep your app's contents up-to-date. The system will fulfill this request intelligently based on system conditions and app usage. + */ + func scheduleAppRefresh() { + let request = BGAppRefreshTaskRequest(identifier: global.refreshTask) + + request.earliestBeginDate = Date(timeIntervalSinceNow: 60) // Refresh after 60 seconds. + + do { + try BGTaskScheduler.shared.submit(request) + } catch { + nkLog(tag: self.global.logTagTask, emoji: .error, message: "Refresh task failed to submit request: \(error)") + } + } + + /* + @discussion Schedule a processing task request to ask that the system launch your app when conditions are favorable for battery life to handle deferrable, longer-running processing, such as syncing, database maintenance, or similar tasks. The system will attempt to fulfill this request to the best of its ability within the next two days as long as the user has used your app within the past week. + */ + func scheduleAppProcessing() { + let request = BGProcessingTaskRequest(identifier: global.processingTask) + + request.earliestBeginDate = Date(timeIntervalSinceNow: 5 * 60) // Refresh after 5 minutes. + request.requiresNetworkConnectivity = false + request.requiresExternalPower = false + + do { + try BGTaskScheduler.shared.submit(request) + } catch { + nkLog(tag: self.global.logTagTask, emoji: .error, message: "Processing task failed to submit request: \(error)") + } + } + + func handleAppRefresh(_ task: BGAppRefreshTask) { + nkLog(tag: self.global.logTagTask, emoji: .start, message: "Start refresh task") + guard NCManageDatabase.shared.openRealmBackground() else { + nkLog(tag: self.global.logTagTask, emoji: .error, message: "Failed to open Realm in background") + task.setTaskCompleted(success: false) + return + } + + // Schedule next refresh + scheduleAppRefresh() + + Task { + defer { + task.setTaskCompleted(success: true) + } + + guard let tblAccount = await NCManageDatabase.shared.getActiveTableAccountAsync() else { + nkLog(tag: self.global.logTagTask, emoji: .info, message: "No active account or background task already running") + return + } + + await backgroundSync(tblAccount: tblAccount, task: task) + } + } + + func handleProcessingTask(_ task: BGProcessingTask) { + nkLog(tag: self.global.logTagTask, emoji: .start, message: "Start processing task") + guard NCManageDatabase.shared.openRealmBackground() else { + nkLog(tag: self.global.logTagTask, emoji: .error, message: "Failed to open Realm in background") + task.setTaskCompleted(success: false) + return + } + var expired = false + task.expirationHandler = { + expired = true + } + + // Schedule next processing task + scheduleAppProcessing() + + Task { + defer { + task.setTaskCompleted(success: true) + } + + // If possible, cleaning every week + if NCPreferences().cleaningWeek() { + // BGTask expiration flag + nkLog(tag: self.global.logTagBgSync, emoji: .start, message: "Start cleaning week") + let tblAccounts = await NCManageDatabase.shared.getAllTableAccountAsync() + for tblAccount in tblAccounts { + await NCManageDatabase.shared.cleanTablesOcIds(account: tblAccount.account, userId: tblAccount.userId, urlBase: tblAccount.urlBase) + guard !expired else { return } + } + await NCUtilityFileSystem().cleanUpAsync() + + NCPreferences().setDoneCleaningWeek() + nkLog(tag: self.global.logTagBgSync, emoji: .stop, message: "Stop cleaning week") + } else { + await backgroundSync(task: task) + } + } + } + + func backgroundSync(task: BGTask? = nil) async { + defer { + // Update badge safely at the end of the background sync + Task { @MainActor in + do { + let count = await NCManageDatabase.shared.getMetadatasInWaitingCountAsync() + try await UNUserNotificationCenter.current().setBadgeCount(count) + } catch { } + } + } + + // BGTask expiration flag + var expired = false + task?.expirationHandler = { + expired = true + } + + // Discover new items for Auto Upload + let numAutoUpload = await NCAutoUpload.shared.initAutoUpload() + nkLog(tag: self.global.logTagBgSync, emoji: .start, message: "Auto upload found \(numAutoUpload) new items") + guard !expired else { return } + + // Fetch METADATAS + let metadatas = await NCManageDatabase.shared.getMetadataProcess() + guard !metadatas.isEmpty, !expired else { + return + } + + // Create all pending Auto Upload folders (fail-fast) + let pendingCreateFolders = metadatas.lazy.filter { + $0.status == self.global.metadataStatusWaitCreateFolder && + $0.sessionSelector == self.global.selectorUploadAutoUpload + } + + for metadata in pendingCreateFolders { + guard !expired else { return } + + let err = await NCNetworking.shared.createFolderForAutoUpload( + serverUrlFileName: meta.serverUrlFileName, + account: meta.account + ) + // Fail-fast: abort the whole sync on first failure + if err != .success { + nkLog(tag: self.global.logTagBgSync, emoji: .error, message: "Create folder '\(meta.serverUrlFileName)' failed: \(err.errorCode) – aborting sync") + return + } + } + + // Capacity computation + let downloading = metadatas.lazy.filter { $0.status == self.global.metadataStatusDownloading }.count + let uploading = metadatas.lazy.filter { $0.status == self.global.metadataStatusUploading }.count + let availableProcess = max(0, NCBrandOptions.shared.numMaximumProcess - (downloading + uploading)) + + // Start Auto Uploads + let metadatasToUpload = Array( + metadatas.lazy.filter { + $0.status == self.global.metadataStatusWaitUpload && + $0.sessionSelector == self.global.selectorUploadAutoUpload && + $0.chunk == 0 + } + .prefix(availableProcess) + ) + + let cameraRoll = NCCameraRoll() + + for metadata in metadatasToUpload { + guard !expired else { return } + + // File exists? skip it + let existsResult = await NCNetworking.shared.fileExists(serverUrlFileName: metadata.serverUrlFileName, account: metadata.account) + if existsResult == .success { + // File exists → delete from local metadata and skip + await NCManageDatabase.shared.deleteMetadataAsync(id: metadata.ocId) + continue + } else if existsResult.errorCode == 404 { + // 404 Not Found → directory does not exist + // Proceed + } else { + // Any other error (423 locked, 401 auth, 403 forbidden, 5xx, etc.) + continue + } + + // Expand seed into concrete metadatas (e.g., Live Photo pair) + let extracted = await cameraRoll.extractCameraRoll(from: metadata) + guard !expired else { return } + + for metadata in extracted { + // Sequential await keeps ordering and simplifies backpressure + let err = await NCNetworking.shared.uploadFileInBackground(metadata: metadata.detachedCopy()) + if err == .success { + nkLog(tag: self.global.logTagBgSync, message: "In queued upload \(metadata.fileName) -> \(metadata.serverUrl)") + } else { + nkLog(tag: self.global.logTagBgSync, emoji: .error, message: "Upload failed \(metadata.fileName) -> \(metadata.serverUrl) [\(err.errorDescription)]") + } + guard !expired else { return } + } + } + } + // MARK: - Background Networking Session func application(_ application: UIApplication, handleEventsForBackgroundURLSession identifier: String, completionHandler: @escaping () -> Void) { @@ -245,7 +470,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } } - // MARK: - + // MARK: - Trust Certificate Error func trustCertificateError(host: String) { guard let activeTblAccount = NCManageDatabase.shared.getActiveTableAccount(), @@ -297,6 +522,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD NCPreferences().removeAll() + // Reset App Icon badge / File Icon badge + if #available(iOS 17.0, *) { + UNUserNotificationCenter.current().setBadgeCount(0) + } exit(0) } @@ -305,6 +534,151 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { return false } + + // MARK: - Login + + func openLogin(selector: Int, window: UIWindow? = nil) { + UIApplication.shared.allSceneSessionDestructionExceptFirst() + + // Nextcloud standard login + if selector == NCGlobal.shared.introSignup { + if activeLogin?.view.window == nil { + if selector == NCGlobal.shared.introSignup { + let web = UIStoryboard(name: "NCLogin", bundle: nil).instantiateViewController(withIdentifier: "NCLoginProvider") as? NCLoginProvider + web?.initialURLString = NCBrandOptions.shared.linkloginPreferredProviders + showLoginViewController(web) + } else { + activeLogin = UIStoryboard(name: "NCLogin", bundle: nil).instantiateViewController(withIdentifier: "NCLogin") as? NCLogin + if let controller = UIApplication.shared.firstWindow?.rootViewController as? NCMainTabBarController, !controller.account.isEmpty { + let session = NCSession.shared.getSession(account: controller.account) + activeLogin?.urlBase = session.urlBase + } + showLoginViewController(activeLogin) + } + } + } else { + if activeLogin?.view.window == nil { + activeLogin = UIStoryboard(name: "NCLogin", bundle: nil).instantiateViewController(withIdentifier: "NCLogin") as? NCLogin + activeLogin?.urlBase = NCBrandOptions.shared.disable_request_login_url ? NCBrandOptions.shared.loginBaseUrl : "" + showLoginViewController(activeLogin) + } + } + } + + func openLogin(viewController: UIViewController?, selector: Int, openLoginWeb: Bool) { +// openLogin(selector: NCGlobal.shared.introLogin) + // [WEBPersonalized] [AppConfig] + if NCBrandOptions.shared.use_login_web_personalized || NCBrandOptions.shared.use_AppConfig { + + if activeLoginWeb?.view.window == nil { + activeLoginWeb = UIStoryboard(name: "NCLogin", bundle: nil).instantiateViewController(withIdentifier: "NCLoginWeb") as? NCLoginWeb + activeLoginWeb?.urlBase = NCBrandOptions.shared.loginBaseUrl + showLoginViewController(activeLoginWeb, contextViewController: viewController) + } + return + } + + // Nextcloud standard login + if selector == NCGlobal.shared.introSignup { + + if activeLoginWeb?.view.window == nil { + activeLoginWeb = UIStoryboard(name: "NCLogin", bundle: nil).instantiateViewController(withIdentifier: "NCLoginWeb") as? NCLoginWeb + if selector == NCGlobal.shared.introSignup { + activeLoginWeb?.urlBase = NCBrandOptions.shared.linkloginPreferredProviders + } else { + activeLoginWeb?.urlBase = self.urlBase + } + showLoginViewController(activeLoginWeb, contextViewController: viewController) + } + + } else if NCBrandOptions.shared.disable_intro && NCBrandOptions.shared.disable_request_login_url { + + if activeLoginWeb?.view.window == nil { + activeLoginWeb = UIStoryboard(name: "NCLogin", bundle: nil).instantiateViewController(withIdentifier: "NCLoginWeb") as? NCLoginWeb + activeLoginWeb?.urlBase = NCBrandOptions.shared.loginBaseUrl + showLoginViewController(activeLoginWeb, contextViewController: viewController) + } + + } else if openLoginWeb { + + // Used also for reinsert the account (change passwd) + if activeLoginWeb?.view.window == nil { + activeLoginWeb = UIStoryboard(name: "NCLogin", bundle: nil).instantiateViewController(withIdentifier: "NCLoginWeb") as? NCLoginWeb + activeLoginWeb?.urlBase = urlBase + activeLoginWeb?.user = user + showLoginViewController(activeLoginWeb, contextViewController: viewController) + } + + } else { + + if activeLogin?.view.window == nil { + activeLogin = UIStoryboard(name: "NCLogin", bundle: nil).instantiateViewController(withIdentifier: "NCLogin") as? NCLogin + showLoginViewController(activeLogin, contextViewController: viewController) + } + } + } + + func showLoginViewController(_ viewController: UIViewController?) { + guard let viewController else { return } + let navigationController = UINavigationController(rootViewController: viewController) + + navigationController.modalPresentationStyle = .fullScreen + navigationController.navigationBar.barStyle = .black + navigationController.navigationBar.tintColor = NCBrandColor.shared.customerText + navigationController.navigationBar.barTintColor = NCBrandColor.shared.customer + navigationController.navigationBar.isTranslucent = false + + if let controller = UIApplication.shared.firstWindow?.rootViewController { + if let presentedVC = controller.presentedViewController, !(presentedVC is UINavigationController) { + presentedVC.dismiss(animated: false) { + controller.present(navigationController, animated: true) + } + } else { + controller.present(navigationController, animated: true) + } + } else { + window?.rootViewController = navigationController + window?.makeKeyAndVisible() + } + } + + func showLoginViewController(_ viewController: UIViewController?, contextViewController: UIViewController?) { + + if contextViewController == nil { + if let viewController = viewController { + let navigationController = UINavigationController(rootViewController: viewController) + navigationController.navigationBar.barStyle = .black + navigationController.navigationBar.tintColor = NCBrandColor.shared.customerText + navigationController.navigationBar.barTintColor = NCBrandColor.shared.customer + navigationController.navigationBar.isTranslucent = false + window?.rootViewController = navigationController + window?.makeKeyAndVisible() + } + } else if contextViewController is UINavigationController { + if let contextViewController = contextViewController, let viewController = viewController { + (contextViewController as? UINavigationController)?.pushViewController(viewController, animated: true) + } + } else { + if let viewController = viewController, let contextViewController = contextViewController { + let navigationController = UINavigationController(rootViewController: viewController) + navigationController.modalPresentationStyle = .fullScreen + navigationController.navigationBar.barStyle = .black + navigationController.navigationBar.tintColor = NCBrandColor.shared.customerText + navigationController.navigationBar.barTintColor = NCBrandColor.shared.customer + navigationController.navigationBar.isTranslucent = false + contextViewController.present(navigationController, animated: true) { } + } + } + } + + @objc func startTimerErrorNetworking() { + timerErrorNetworking = Timer.scheduledTimer(timeInterval: 3, target: self, selector: #selector(checkErrorNetworking), userInfo: nil, repeats: true) + } + + @objc private func checkErrorNetworking() { + guard !account.isEmpty, NCKeychain().getPassword(account: account).isEmpty else { return } + openLogin(viewController: window?.rootViewController, selector: NCGlobal.shared.introLogin, openLoginWeb: true) + } } // MARK: - Extension diff --git a/iOSClient/Data/NCManageDatabase+Metadata.swift b/iOSClient/Data/NCManageDatabase+Metadata.swift index 2892c7cae0..105c39d883 100644 --- a/iOSClient/Data/NCManageDatabase+Metadata.swift +++ b/iOSClient/Data/NCManageDatabase+Metadata.swift @@ -151,10 +151,37 @@ extension tableMetadata { (fileNameView as NSString).deletingPathExtension } + var isRenameable: Bool { + if !NCMetadataPermissions.canRename(self) { + return false + } + if lock { + return false + } + if !isDirectoryE2EE && e2eEncrypted { + return false + } + return true + } + + var isPrintable: Bool { + if isDocumentViewableOnly { + return false + } + if ["application/pdf", "com.adobe.pdf"].contains(contentType) || contentType.hasPrefix("text/") || classFile == NKTypeClassFile.image.rawValue { + return true + } + return false + } + var isSavebleInCameraRoll: Bool { return (classFile == NKTypeClassFile.image.rawValue && contentType != "image/svg+xml") || classFile == NKTypeClassFile.video.rawValue } + var isDocumentViewableOnly: Bool { + sharePermissionsCollaborationServices == NCPermissions().permissionReadShare && classFile == NKTypeClassFile.document.rawValue + } + var isAudioOrVideo: Bool { return classFile == NKTypeClassFile.audio.rawValue || classFile == NKTypeClassFile.video.rawValue } @@ -231,10 +258,54 @@ extension tableMetadata { guard let capabilities = NCNetworking.shared.capabilities[account] else { return false } - if !capabilities.fileSharingApiEnabled || (capabilities.e2EEEnabled && isDirectoryE2EE) { - return false - } - return true + let utility = NCUtility() + let editors = utility.editorsDirectEditing(account: account, contentType: contentType) + let isRichDocument = utility.isTypeFileRichDocument(self) + return classFile == NKCommon.TypeClassFile.document.rawValue && editors.contains(NCGlobal.shared.editorText) && ((editors.contains(NCGlobal.shared.editorOnlyoffice) || isRichDocument)) + } + + var isWaitingTransfer: Bool { + status == NCGlobal.shared.metadataStatusWaitDownload || status == NCGlobal.shared.metadataStatusWaitUpload || status == NCGlobal.shared.metadataStatusUploadError + } + + var isInTransfer: Bool { + status == NCGlobal.shared.metadataStatusDownloading || status == NCGlobal.shared.metadataStatusUploading + } + + var isTransferInForeground: Bool { + (status > 0 && (chunk > 0 || e2eEncrypted)) + } + + var isDownloadUpload: Bool { + status == NCGlobal.shared.metadataStatusDownloading || status == NCGlobal.shared.metadataStatusUploading + } + + var isDownload: Bool { + status == NCGlobal.shared.metadataStatusWaitDownload || status == NCGlobal.shared.metadataStatusDownloading + } + + var isUpload: Bool { + status == NCGlobal.shared.metadataStatusWaitUpload || status == NCGlobal.shared.metadataStatusUploading + } + + var isDirectory: Bool { + directory + } + + @objc var isDirectoryE2EE: Bool { + return NCUtilityFileSystem().isDirectoryE2EE(serverUrl: serverUrl, urlBase: urlBase, userId: userId, account: account) + } + + var isLivePhoto: Bool { + !livePhotoFile.isEmpty + } + + var isNotFlaggedAsLivePhotoByServer: Bool { + !isFlaggedAsLivePhotoByServer + } + + var imageSize: CGSize { + CGSize(width: width, height: height) } var hasPreviewBorder: Bool { @@ -343,6 +414,17 @@ extension tableMetadata { return !lock || (lockOwner == user && lockOwnerType == 0) } + // Return if is sharable + func isSharable() -> Bool { + guard let capabilities = NCNetworking.shared.capabilities[account] else { + return false + } + if !capabilities.fileSharingApiEnabled || (capabilities.e2EEEnabled && isDirectoryE2EE), !e2eEncrypted { + return false + } + return !e2eEncrypted + } + /// Returns a detached (unmanaged) deep copy of the current `tableMetadata` object. /// /// - Note: Primitive properties and lists of primitive values (for example `shareType`) @@ -917,6 +999,34 @@ extension NCManageDatabase { .map { $0.detachedCopy() } } ?? [] } + + func getMediaMetadatas(predicate: NSPredicate, sorted: String? = nil, ascending: Bool = false) -> ThreadSafeArray? { + + do { + let realm = try Realm() + if let sorted { + var results: [tableMetadata] = [] + switch sorted {//NCPreferences().mediaSortDate { + case "date": + results = realm.objects(tableMetadata.self).filter(predicate).sorted { ($0.date as Date) > ($1.date as Date) } + case "creationDate": + results = realm.objects(tableMetadata.self).filter(predicate).sorted { ($0.creationDate as Date) > ($1.creationDate as Date) } + case "uploadDate": + results = realm.objects(tableMetadata.self).filter(predicate).sorted { ($0.uploadDate as Date) > ($1.uploadDate as Date) } + default: + let results = realm.objects(tableMetadata.self).filter(predicate) + return ThreadSafeArray(results.map { tableMetadata.init(value: $0) }) + } + return ThreadSafeArray(results.map { tableMetadata.init(value: $0) }) + } else { + let results = realm.objects(tableMetadata.self).filter(predicate) + return ThreadSafeArray(results.map { tableMetadata.init(value: $0) }) + } + } catch let error as NSError { +// NextcloudKit.shared.nkCommonInstance.writeLog("Could not access database: \(error)") + } + return nil + } func getMetadatas(predicate: NSPredicate, sortedByKeyPath: String, @@ -1194,6 +1304,24 @@ extension NCManageDatabase { } ?? [] } + func getTableMetadatasDirectoryFavoriteIdentifierRankAsync(account: String) async -> [String: NSNumber] { + let result = await performRealmReadAsync { realm in + var listIdentifierRank: [String: NSNumber] = [:] + var counter = Int64(10) + + let results = realm.objects(tableMetadata.self) + .filter("account == %@ AND directory == true AND favorite == true", account) + .sorted(byKeyPath: "fileNameView", ascending: true) + + results.forEach { item in + counter += 1 + listIdentifierRank[item.ocId] = NSNumber(value: counter) + } + return listIdentifierRank + } + return result ?? [:] + } + func getAssetLocalIdentifiersUploadedAsync() async -> [String]? { return await core.performRealmReadAsync { realm in let results = realm.objects(tableMetadata.self).filter("assetLocalIdentifier != ''") @@ -1342,6 +1470,7 @@ extension NCManageDatabase { .first return object?.detachedCopy() } + return nil } func getTransferAsync(tranfersSuccess: [tableMetadata]) async -> [tableMetadata] { diff --git a/iOSClient/Media/Cell/NCMediaCell.swift b/iOSClient/Media/Cell/NCMediaCell.swift index dc9ea1f615..f2568ad922 100644 --- a/iOSClient/Media/Cell/NCMediaCell.swift +++ b/iOSClient/Media/Cell/NCMediaCell.swift @@ -24,6 +24,7 @@ import UIKit class NCMediaCell: UICollectionViewCell { + @IBOutlet weak var imageItem: UIImageView! @IBOutlet weak var imageVisualEffect: UIVisualEffectView! @IBOutlet weak var imageSelect: UIImageView! @@ -45,17 +46,21 @@ class NCMediaCell: UICollectionViewCell { func initCell() { imageStatus.image = nil imageItem.image = nil - - imageVisualEffect.isHidden = false - imageVisualEffect.effect = nil - imageVisualEffect.alpha = 0 - imageVisualEffect.isUserInteractionEnabled = false - imageVisualEffect.backgroundColor = UIColor.white.withAlphaComponent(0.2) + imageVisualEffect.alpha = 0.4 + imageSelect.image = NCImageCache.shared.getImageCheckedYes() + imageVisualEffect.isHidden = true + imageSelect.isHidden = true } - func selected(_ status: Bool, color: UIColor) { - imageVisualEffect.alpha = status ? 1 : 0 - imageSelect.alpha = status ? 1 : 0 - imageSelect.image = NCImageCache.shared.getImageCheckedYes(color: color) + func selected(_ status: Bool) { + if status { +// imageSelect.isHidden = false + imageVisualEffect.isHidden = false + imageSelect.image = NCImageCache.shared.getImageCheckedYes() + } else { +// imageSelect.isHidden = true + imageVisualEffect.isHidden = true + imageSelect.image = NCImageCache.shared.getImageCheckedNo() + } } } diff --git a/iOSClient/Media/Cell/NCMediaCell.xib b/iOSClient/Media/Cell/NCMediaCell.xib index 55163a7052..56d8a386e5 100644 --- a/iOSClient/Media/Cell/NCMediaCell.xib +++ b/iOSClient/Media/Cell/NCMediaCell.xib @@ -1,8 +1,9 @@ - + - + + @@ -26,15 +27,16 @@ - + - + diff --git a/iOSClient/Media/NCMedia.storyboard b/iOSClient/Media/NCMedia.storyboard index fcdb7e83f1..52dc584423 100644 --- a/iOSClient/Media/NCMedia.storyboard +++ b/iOSClient/Media/NCMedia.storyboard @@ -1,8 +1,8 @@ - + - + @@ -39,13 +39,13 @@ @@ -68,6 +68,8 @@ + + diff --git a/iOSClient/Media/NCMedia.swift b/iOSClient/Media/NCMedia.swift index 7399eb86ce..eef19314cd 100644 --- a/iOSClient/Media/NCMedia.swift +++ b/iOSClient/Media/NCMedia.swift @@ -7,11 +7,23 @@ import UIKit import NextcloudKit import RealmSwift +protocol NCMediaSelectionDelegate: AnyObject { + func didUpdateSelection(files: [String]) +} + class NCMedia: UIViewController { @IBOutlet weak var collectionView: UICollectionView! @IBOutlet weak var titleDate: UILabel! @IBOutlet weak var activityIndicator: UIActivityIndicatorView! + @IBOutlet weak var titleConstraint: NSLayoutConstraint! @IBOutlet weak var gradientView: UIView! + @IBOutlet weak var gradientViewHeightContsraint: NSLayoutConstraint! + + // Called when initial media data has finished loading + var onInitialLoadCompleted: (() -> Void)? + + let semaphoreSearchMedia = DispatchSemaphore(value: 1) + let semaphoreNotificationCenter = DispatchSemaphore(value: 1) let layout = NCMediaLayout() let gradientLayer = CAGradientLayer() @@ -25,20 +37,39 @@ class NCMedia: UIViewController { let imageCache = NCImageCache.shared let networking = NCNetworking.shared var dataSource = NCMediaDataSource() + let refreshControl = UIRefreshControl() + var isTop: Bool = true var isEditMode = false - var fileSelect: [String] = [] +// var fileSelect: [String] = [] + // 1. Add this property here (NOT in an extension) + weak var selectionDelegate: NCMediaSelectionDelegate? + + // 2. Find your existing fileSelect array and add the didSet + var fileSelect: [String] = [] { + didSet { + selectionDelegate?.didUpdateSelection(files: fileSelect) + } + } + var filesExists: ThreadSafeArray = ThreadSafeArray() + var ocIdDoNotExists: ThreadSafeArray = ThreadSafeArray() var searchMediaInProgress: Bool = false + // Tracks whether we have completed an explicit preload before presentation + private var didCompleteInitialPreload = false + private var explicitPreloadTask: Task? + var attributesZoomIn: UIMenuElement.Attributes = [] var attributesZoomOut: UIMenuElement.Attributes = [] var showOnlyImages = false var showOnlyVideos = false var timeIntervalSearchNewMedia: TimeInterval = 2.0 var timerSearchNewMedia: Timer? + let insetsTop: CGFloat = 0//75//65 let livePhotoImage = NCUtility().loadImage(named: "livephoto", colors: [.white]) let playImage = NCUtility().loadImage(named: "play.fill", colors: [.white]) var photoImage = UIImage() var videoImage = UIImage() var pinchGesture: UIPinchGestureRecognizer = UIPinchGestureRecognizer() + var metadatas: ThreadSafeArray? var lastScale: CGFloat = 1.0 var currentScale: CGFloat = 1.0 @@ -51,6 +82,14 @@ class NCMedia: UIViewController { var transitionColumns = false var numberOfColumns: Int = 0 var lastNumberOfColumns: Int = 0 + var loadingTask: Task? + var mediaCommandView: NCMediaCommandView? + var activeAccount = tableAccount() + var lastContentOffsetY: CGFloat = 0 + let maxImageGrid: CGFloat = 7 + var hiddenCellMetadats: ThreadSafeArray = ThreadSafeArray() + + var isInGeneralPhotosSelectionContext: Bool = false let debouncerLoadDataSource = NCDebouncer(maxEventCount: 10) let debouncerSearch = NCDebouncer(maxEventCount: 10) @@ -60,7 +99,6 @@ class NCMedia: UIViewController { NCSession.shared.getSession(controller: tabBarController) } - @MainActor var controller: NCMainTabBarController? { self.tabBarController as? NCMainTabBarController } @@ -73,14 +111,33 @@ class NCMedia: UIViewController { return pinchGesture.state == .began || pinchGesture.state == .changed } - @MainActor var sceneIdentifier: String { (self.tabBarController as? NCMainTabBarController)?.sceneIdentifier ?? "" } +// var isInGeneralPhotosSelectionContext: Bool = false + + // MARK: - Programmatic Preload API + /// Preloads the media data (data source and initial search) so that the controller is ready when presented. + /// Safe to call while the media tab hasn't been opened yet. Idempotent across multiple calls. @MainActor - internal var windowScene: UIWindowScene? { - SceneManager.shared.getWindowScene(controller: self.tabBarController as? NCMainTabBarController) + func preloadIfNeeded() { + // Avoid re-running if already completed + if didCompleteInitialPreload { return } + // Cancel any previous explicit preload + explicitPreloadTask?.cancel() + explicitPreloadTask = Task { [weak self] in + guard let self else { return } + // Ensure view is loaded to set up collectionView/layout safely + _ = self.view + // Run the same loading sequence used in view lifecycle, but explicitly + await self.loadDataSource() + await self.searchMediaUI(true) + self.didCompleteInitialPreload = true + await MainActor.run { + self.onInitialLoadCompleted?() + } + } } // MARK: - View Life Cycle @@ -89,12 +146,14 @@ class NCMedia: UIViewController { super.viewDidLoad() view.backgroundColor = .systemBackground + navigationController?.setNavigationBarAppearance() collectionView.register(UINib(nibName: "NCSectionFirstHeaderEmptyData", bundle: nil), forSupplementaryViewOfKind: mediaSectionHeader, withReuseIdentifier: "sectionFirstHeaderEmptyData") collectionView.register(UINib(nibName: "NCSectionFooter", bundle: nil), forSupplementaryViewOfKind: mediaSectionFooter, withReuseIdentifier: "sectionFooter") collectionView.register(UINib(nibName: "NCMediaCell", bundle: nil), forCellWithReuseIdentifier: "mediaCell") collectionView.alwaysBounceVertical = true collectionView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + collectionView.contentInset = isInGeneralPhotosSelectionContext ? UIEdgeInsets(top: 10, left: 0, bottom: 50, right: 0) : UIEdgeInsets(top: insetsTop, left: 0, bottom: 50, right: 0) collectionView.backgroundColor = .systemBackground collectionView.prefetchDataSource = self collectionView.dragInteractionEnabled = true @@ -105,7 +164,14 @@ class NCMedia: UIViewController { layout.sectionInset = UIEdgeInsets(top: 0, left: 2, bottom: 0, right: 2) collectionView.collectionViewLayout = layout - layoutType = database.getLayoutForView(account: session.account, key: global.layoutViewMedia, serverUrl: "", layoutType: global.mediaLayoutRatio).layout + layoutType = database.getLayoutForView(account: session.account, key: global.layoutViewMedia, serverUrl: "", layout: global.mediaLayoutRatio).layout + +// tabBarSelect = NCMediaSelectTabBar(controller: self.tabBarController, viewController: self, delegate: self) + + titleDate.text = "" + titleDate.isHidden = true + + isEditMode = isInGeneralPhotosSelectionContext ? true : false // Gradient Layer gradientLayer.startPoint = CGPoint(x: 0, y: 0) @@ -125,6 +191,30 @@ class NCMedia: UIViewController { gradientLayer.locations = [0.0, 0.20, 0.40, 0.60, 0.75, 0.85, 0.95, 1.0] gradientView.layer.insertSublayer(gradientLayer, at: 0) + activeAccount = NCManageDatabase.shared.getActiveTableAccount() ?? tableAccount() + + collectionView.refreshControl = refreshControl + refreshControl.action(for: .valueChanged) { _ in + DispatchQueue.global().async { + Task { + await self.loadDataSource() + await self.searchMediaUI(true) + } + } + self.refreshControl.endRefreshing() + } + + // Title + Activity indicator + if UIDevice.current.userInterfaceIdiom == .pad { + titleConstraint.constant = 0 + } else { + if #available(iOS 26.0, *) { + titleConstraint.constant = -44 + } else { + titleConstraint.constant = -34 + } + } + titleDate.text = "" titleDate?.textColor = .white activityIndicator.color = .white @@ -154,6 +244,12 @@ class NCMedia: UIViewController { } } + NotificationCenter.default.addObserver(self, selector: #selector(fileExists(_:)), name: NSNotification.Name(rawValue: global.notificationCenterFileExists), object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(deleteFile(_:)), name: NSNotification.Name(rawValue: global.notificationCenterDeleteFile), object: nil) + + NotificationCenter.default.addObserver(self, selector: #selector(reloadDataSource(_:)), name: NSNotification.Name(rawValue: global.notificationCenterReloadDataSource), object: nil) + NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { _ in Task { await self.networkRemoveAll() @@ -167,6 +263,7 @@ class NCMedia: UIViewController { if tabBarSelect == nil { tabBarSelect = NCMediaSelectTabBar(controller: self.tabBarController, viewController: self, delegate: self) } +// navigationController?.setMediaAppreance() Task { await (self.navigationController as? NCMediaNavigationController)?.setNavigationRightItems() @@ -180,17 +277,33 @@ class NCMedia: UIViewController { await loadDataSource() } } + Task { + if !self.didCompleteInitialPreload { + await self.loadDataSource() + await self.searchMediaUI(true) + } + } + AnalyticsHelper.shared.trackEvent(eventName: .SCREEN_EVENT__MEDIA) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + // Re-evaluate in-app messages after viewDidAppear + MoEngageAnalytics.shared.displayInAppNotificationSafely(reason: "viewDidAppear") + Task { await networking.transferDispatcher.addDelegate(self) } + NotificationCenter.default.addObserver(self, selector: #selector(copyMoveFile(_:)), name: NSNotification.Name(rawValue: global.notificationCenterCopyMoveFile), object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(enterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil) + if !didCompleteInitialPreload { + onInitialLoadCompleted?() + } + searchNewMedia() } @@ -202,15 +315,30 @@ class NCMedia: UIViewController { await networkRemoveAll() } + NotificationCenter.default.removeObserver(self, name: NSNotification.Name(rawValue: global.notificationCenterCopyMoveFile), object: nil) + NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil) } override func viewWillLayoutSubviews() { super.viewWillLayoutSubviews() +// if let frame = tabBarController?.tabBar.frame { +// tabBarSelect.hostingController?.view.frame = frame +// } gradientLayer.frame = gradientView.bounds } + override var preferredStatusBarStyle: UIStatusBarStyle { + if self.traitCollection.userInterfaceStyle == .dark { + return .lightContent + } else if isTop { + return .darkContent + } else { + return .lightContent + } + } + func searchNewMedia() { timerSearchNewMedia?.invalidate() timerSearchNewMedia = Timer.scheduledTimer(withTimeInterval: timeIntervalSearchNewMedia, repeats: false) { [weak self] _ in @@ -226,7 +354,9 @@ class NCMedia: UIViewController { func networkRemoveAll() async { timerSearchNewMedia?.invalidate() timerSearchNewMedia = nil + filesExists.removeAll() + NCNetworking.shared.fileExistsQueue.cancelAll() networking.downloadThumbnailQueue.cancelAll() let tasks = await networking.getAllDataTask() @@ -235,10 +365,89 @@ class NCMedia: UIViewController { } } + @objc func reloadDataSource(_ notification: NSNotification) { + Task { + await self.loadDataSource() + } + } + + @objc func deleteFile(_ notification: NSNotification) { + guard let userInfo = notification.userInfo as NSDictionary?, + let error = userInfo["error"] as? NKError + else { + return + } + + // This is only a fail safe "dead lock", I don't think the timeout will ever be called but at least nothing gets stuck, if after 5 sec. (which is a long time in this routine), the semaphore is still locked + // +// if self.semaphoreNotificationCenter.wait(timeout: .now() + 5) == .timedOut { +// self.semaphoreNotificationCenter.signal() +// } + + if error.errorCode == self.global.errorResourceNotFound, + let ocIds = userInfo["ocId"] as? [String], + let ocId = ocIds.first { + Task { + await NCManageDatabase.shared.deleteMetadataAsync(ocId: ocId) + await self.loadDataSource() +// { +// self.semaphoreNotificationCenter.signal() +// } + } + } else if error != .success { + Task { + await self.loadDataSource() + } +// self.loadDataSource { +// self.semaphoreNotificationCenter.signal() +// } + } else { +// semaphoreNotificationCenter.signal() + } + } + @objc func enterForeground(_ notification: NSNotification) { searchNewMedia() } + @objc func fileExists(_ notification: NSNotification) { + guard let userInfo = notification.userInfo as NSDictionary?, + let ocId = userInfo["ocId"] as? String, + let fileExists = userInfo["fileExists"] as? Bool + else { + return + } + + filesExists.append(ocId) + if !fileExists { + ocIdDoNotExists.append(ocId) + } + + if NCNetworking.shared.fileExistsQueue.operationCount == 0, + !ocIdDoNotExists.isEmpty, + let ocIdDoNotExists = self.ocIdDoNotExists.getArray() { + dataSource.removeMetadata(ocIdDoNotExists) + database.deleteMetadataOcIds(ocIdDoNotExists) + self.ocIdDoNotExists.removeAll() + collectionViewReloadData() + } + } + + @objc func copyMoveFile(_ notification: NSNotification) { + guard let userInfo = notification.userInfo as NSDictionary?, + let dragDrop = userInfo["dragdrop"] as? Bool, + dragDrop else { return } + + setEditMode(false) + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + Task { + await self.loadDataSource() + await self.searchMediaUI() + } + } + } + func buildMediaPhotoVideo(columnCount: Int) { var pointSize: CGFloat = 0 @@ -256,6 +465,48 @@ class NCMedia: UIViewController { videoImage = image } } + + // MARK: - Command + + func setupMediaCommandView() { + mediaCommandView?.title.text = "" + + mediaCommandView = Bundle.main.loadNibNamed("NCMediaCommandView", owner: self, options: nil)?.first as? NCMediaCommandView + self.view.addSubview(mediaCommandView!) + mediaCommandView?.mediaView = self +// updateZoomButton() + mediaCommandView?.collapseControlButtonView(true) + mediaCommandView?.translatesAutoresizingMaskIntoConstraints = false + mediaCommandView?.topAnchor.constraint(equalTo: view.topAnchor, constant: 0).isActive = true + mediaCommandView?.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0).isActive = true + mediaCommandView?.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0).isActive = true + mediaCommandView?.heightAnchor.constraint(equalToConstant: 150).isActive = true + self.updateMediaControlVisibility() + } + + private func setupForGeneralPhotosSelection() { + if isInGeneralPhotosSelectionContext { + gradientViewHeightContsraint.constant = 0 + mediaCommandView?.setupForGeneralPhotosSelection() + isEditMode = true + } + } + + func updateMediaControlVisibility() { + + if let metadatas = self.metadatas, metadatas.isEmpty { + if !self.showOnlyImages && !self.showOnlyVideos { + self.mediaCommandView?.toggleEmptyView(isEmpty: true) + self.mediaCommandView?.isHidden = false + } else { + self.mediaCommandView?.toggleEmptyView(isEmpty: true) + self.mediaCommandView?.isHidden = false + } + } else { + self.mediaCommandView?.toggleEmptyView(isEmpty: false) + self.mediaCommandView?.isHidden = false + } + } } // MARK: - @@ -263,7 +514,12 @@ class NCMedia: UIViewController { extension NCMedia: UIScrollViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { if !dataSource.metadatas.isEmpty { - setTitleDate() + isTop = scrollView.contentOffset.y <= -(insetsTop + view.safeAreaInsets.top - 25) +// setTitleDate() + if lastContentOffsetY == 0 || lastContentOffsetY / 2 <= scrollView.contentOffset.y || lastContentOffsetY / 2 >= scrollView.contentOffset.y { + setTitleDate() + lastContentOffsetY = scrollView.contentOffset.y + } setNeedsStatusBarAppearanceUpdate() } setElements() @@ -284,13 +540,16 @@ extension NCMedia: UIScrollViewDelegate { searchNewMedia() } - func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { } + func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { + let y = view.safeAreaInsets.top + scrollView.contentOffset.y = -(insetsTop + y) + } } // MARK: - extension NCMedia: NCSelectDelegate { - func dismissSelect(serverUrl: String?, metadata: tableMetadata?, type: String, items: [Any], overwrite: Bool, copy: Bool, move: Bool, session: NCSession.Session, controller: NCMainTabBarController?) { + func dismissSelect(serverUrl: String?, metadata: tableMetadata?, type: String, items: [Any], overwrite: Bool, copy: Bool, move: Bool, session: NCSession.Session) { guard let serverUrl else { return } Task { @@ -305,3 +564,109 @@ extension NCMedia: NCSelectDelegate { } } } + +// MARK: - Media Command View + +class NCMediaCommandView: UIView { + + @IBOutlet weak var moreView: UIVisualEffectView! + @IBOutlet weak var gridSwitchButton: UIButton! + @IBOutlet weak var separatorView: UIView! + @IBOutlet weak var buttonControlWidthConstraint: NSLayoutConstraint! + @IBOutlet weak var zoomInButton: UIButton! + @IBOutlet weak var zoomOutButton: UIButton! + @IBOutlet weak var moreButton: UIButton! + @IBOutlet weak var controlButtonView: UIVisualEffectView! + @IBOutlet weak var title: UILabel! + @IBOutlet weak var activityIndicator: UIActivityIndicatorView! + + var mediaView: NCMedia? + private let gradient: CAGradientLayer = CAGradientLayer() + + override func awakeFromNib() { + moreView.layer.cornerRadius = 20 + moreView.layer.masksToBounds = true + controlButtonView.layer.cornerRadius = 20 + controlButtonView.layer.masksToBounds = true + controlButtonView.effect = UIBlurEffect(style: .dark) + gradient.frame = bounds + gradient.startPoint = CGPoint(x: 0, y: 0.5) + gradient.endPoint = CGPoint(x: 0, y: 1) + gradient.colors = [UIColor.black.withAlphaComponent(UIAccessibility.isReduceTransparencyEnabled ? 0.8 : 0.4).cgColor, UIColor.clear.cgColor] + layer.insertSublayer(gradient, at: 0) + moreButton.setImage(UIImage(named: "more")!.image(color: .white, size: 25), for: .normal) + title.text = "" + } + + func setupForGeneralPhotosSelection() { + gridSwitchButton.isHidden = true + moreView.isHidden = true + title.isHidden = true + controlButtonView.isHidden = true + gradient.isHidden = true + } + + func toggleEmptyView(isEmpty: Bool) { + if isEmpty { + UIView.animate(withDuration: 0.3) { + self.moreView.effect = UIBlurEffect(style: .dark) + self.gradient.isHidden = true + self.controlButtonView.isHidden = true + } + } else { + UIView.animate(withDuration: 0.3) { + self.moreView.effect = UIBlurEffect(style: .dark) + self.gradient.isHidden = false + self.controlButtonView.isHidden = false + } + } + } + + @IBAction func moreButtonPressed(_ sender: UIButton) { +// mediaView?.openMenuButtonMore(sender) + } + + @IBAction func zoomInPressed(_ sender: UIButton) { +// mediaView?.zoomInGrid() + } + + @IBAction func zoomOutPressed(_ sender: UIButton) { +// mediaView?.zoomOutGrid() + } + + @IBAction func gridSwitchButtonPressed(_ sender: Any) { + self.collapseControlButtonView(false) + } + + func collapseControlButtonView(_ collapse: Bool) { + if collapse { + self.buttonControlWidthConstraint.constant = 40 + UIView.animate(withDuration: 0.25) { + self.zoomOutButton.isHidden = true + self.zoomInButton.isHidden = true + self.separatorView.isHidden = true + self.gridSwitchButton.isHidden = false + self.layoutIfNeeded() + } + } else { + self.buttonControlWidthConstraint.constant = 80 + UIView.animate(withDuration: 0.25) { + self.zoomOutButton.isHidden = false + self.zoomInButton.isHidden = false + self.separatorView.isHidden = false + self.gridSwitchButton.isHidden = true + self.layoutIfNeeded() + } + } + } + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + return moreView.frame.contains(point) || controlButtonView.frame.contains(point) + } + + override func layoutSublayers(of layer: CALayer) { + super.layoutSublayers(of: layer) + gradient.frame = bounds + } +} + diff --git a/iOSClient/Media/NCMediaCommandView.xib b/iOSClient/Media/NCMediaCommandView.xib new file mode 100644 index 0000000000..b2df2d68eb --- /dev/null +++ b/iOSClient/Media/NCMediaCommandView.xib @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOSClient/Menu/NCMedia+Menu.swift b/iOSClient/Menu/NCMedia+Menu.swift new file mode 100644 index 0000000000..74a3e33179 --- /dev/null +++ b/iOSClient/Menu/NCMedia+Menu.swift @@ -0,0 +1,218 @@ +// +// NCMedia+Menu.swift +// Nextcloud +// +// Created by Marino Faggiana on 03/03/2021. +// Copyright © 2021 Marino Faggiana. All rights reserved. +// +// Author Marino Faggiana +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . +// + +import UIKit +import FloatingPanel +import NextcloudKit + +extension NCMedia { + func tapSelect() { + self.isEditMode = false + self.fileSelect.removeAll() + self.collectionView?.reloadData() + } + + func selectAll() { + if !fileSelect.isEmpty, self.dataSource.metadatas.count == fileSelect.count { + fileSelect = [] + } else { + fileSelect = self.dataSource.metadatas.compactMap({ $0.ocId }) + } + self.collectionView.reloadData() + } + + func toggleMenu() { + +// var actions: [NCMenuAction] = [] +// +// defer { presentMenu(with: actions) } +// +// if !isEditMode { +// if let metadatas = self.metadatas, !metadatas.isEmpty { +// actions.append( +// NCMenuAction( +// title: NSLocalizedString("_select_", comment: ""), +// icon: utility.loadImage(named: "selectFull", colors: [NCBrandColor.shared.iconImageColor]), +// action: { _ in +// self.isEditMode = true +// self.collectionView.reloadData() +// } +// ) +// ) +// } +// +// actions.append(.seperator(order: 0)) +// +// actions.append( +// NCMenuAction( +// title: NSLocalizedString("_media_viewimage_show_", comment: ""), +// icon: utility.loadImage(named: showOnlyImages ? "nocamera" : "file_photo_menu", colors: [NCBrandColor.shared.iconImageColor]), +// selected: showOnlyImages, +// on: true, +// action: { _ in +// self.showOnlyImages = true +// self.showOnlyVideos = false +// self.loadDataSource() +// } +// ) +// ) +// +// actions.append( +// NCMenuAction( +// title: NSLocalizedString("_media_viewvideo_show_", comment: ""), +// icon: utility.loadImage(named: showOnlyVideos ? "videono" : "videoyes", colors: [NCBrandColor.shared.iconImageColor]), +// selected: showOnlyVideos, +// on: true, +// action: { _ in +// self.showOnlyImages = false +// self.showOnlyVideos = true +// self.loadDataSource() +// } +// ) +// ) +// +// actions.append( +// NCMenuAction( +// title: NSLocalizedString("_media_show_all_", comment: ""), +// icon: utility.loadImage(named: "photo.on.rectangle.angled", colors: [NCBrandColor.shared.iconImageColor]), +// selected: !showOnlyImages && !showOnlyVideos, +// on: true, +// action: { _ in +// self.showOnlyImages = false +// self.showOnlyVideos = false +// self.loadDataSource() +// } +// ) +// ) +// +// actions.append(.seperator(order: 0)) +// +// actions.append( +// NCMenuAction( +// title: NSLocalizedString("_select_media_folder_", comment: ""), +// icon: utility.loadImage(named: "folder", colors: [NCBrandColor.shared.iconImageColor]), +// action: { _ in +// if let navigationController = UIStoryboard(name: "NCSelect", bundle: nil).instantiateInitialViewController() as? UINavigationController, +// let viewController = navigationController.topViewController as? NCSelect { +// +// viewController.delegate = self +// viewController.typeOfCommandView = .select +// viewController.type = "mediaFolder" +// +// self.present(navigationController, animated: true, completion: nil) +// } +// } +// ) +// ) +// +// actions.append( +// NCMenuAction( +// title: NSLocalizedString("_media_by_modified_date_", comment: ""), +// icon: utility.loadImage(named: "sortFileNameAZ", colors: [NCBrandColor.shared.iconImageColor]), +// selected: NCKeychain().mediaSortDate == "date", +// on: true, +// action: { _ in +// NCKeychain().mediaSortDate = "date" +// self.loadDataSource() +// } +// ) +// ) +// +// actions.append( +// NCMenuAction( +// title: NSLocalizedString("_media_by_created_date_", comment: ""), +// icon: utility.loadImage(named: "sortFileNameAZ", colors: [NCBrandColor.shared.iconImageColor]), +// selected: NCKeychain().mediaSortDate == "creationDate", +// on: true, +// action: { _ in +// NCKeychain().mediaSortDate = "creationDate" +// self.loadDataSource() +// } +// ) +// ) +// +// actions.append( +// NCMenuAction( +// title: NSLocalizedString("_media_by_upload_date_", comment: ""), +// icon: utility.loadImage(named: "sortFileNameAZ", colors: [NCBrandColor.shared.iconImageColor]), +// selected: NCKeychain().mediaSortDate == "uploadDate", +// on: true, +// action: { _ in +// NCKeychain().mediaSortDate = "uploadDate" +// self.loadDataSource() +// } +// ) +// ) +// +// } else { +// +// // +// // CANCEL +// // +// actions.append( +// NCMenuAction( +// title: NSLocalizedString("_cancel_", comment: ""), +// icon: utility.loadImage(named: "xmark", colors: [NCBrandColor.shared.iconImageColor]), +// action: { _ in self.tapSelect() } +// ) +// ) +// +// if fileSelect.count != dataSource.metadatas.count { +// actions.append(.selectAllAction(action: selectAll)) +// } +// guard !fileSelect.isEmpty else { return } +// +// +// actions.append(.seperator(order: 0)) +// +// let selectedMetadatas = fileSelect.compactMap(NCManageDatabase.shared.getMetadataFromOcId) +// +// // +// // OPEN IN +// // +// actions.append(.openInAction(selectedMetadatas: selectedMetadatas, controller: self.controller, completion: tapSelect)) +// +// // +// // SAVE TO PHOTO GALLERY +// // +// actions.append(.saveMediaAction(selectedMediaMetadatas: selectedMetadatas, controller: self.controller, completion: tapSelect)) +// +// // +// // COPY - MOVE +// // +// actions.append(.moveOrCopyAction(selectedMetadatas: selectedMetadatas, controller: self.controller, completion: tapSelect)) +// +// // +// // COPY +// // +// actions.append(.copyAction(fileSelect: fileSelect, controller: self.controller, completion: tapSelect)) +// +// // +// // DELETE +// // can't delete from cache because is needed for NCMedia view, and if locked can't delete from server either. +// if !selectedMetadatas.contains(where: { $0.lock && $0.lockOwner != session.userId }) { +// actions.append(.deleteAction(selectedMetadatas: selectedMetadatas, controller: self.controller, completion: tapSelect)) +// } +// } + } +} diff --git a/iOSClient/NCImageCache.swift b/iOSClient/NCImageCache.swift index eb40357d0f..ba4daa6fad 100644 --- a/iOSClient/NCImageCache.swift +++ b/iOSClient/NCImageCache.swift @@ -19,8 +19,6 @@ final class NCImageCache: @unchecked Sendable { private let allowExtensions = [NCGlobal.shared.previewExt256] private var brandElementColor: UIColor? - private var observerToken: NSObjectProtocol? - public var countLimit: Int = 2000 lazy var cache: LRUCache = { return LRUCache(countLimit: countLimit) @@ -29,6 +27,8 @@ final class NCImageCache: @unchecked Sendable { public var isLoadingCache: Bool = false public var controller: UITabBarController? + let showBothPredicateMediaString = "account == %@ AND serverUrl BEGINSWITH %@ AND (classFile == '\(NKTypeClassFile.image.rawValue)' OR classFile == '\(NKTypeClassFile.video.rawValue)') AND NOT (session CONTAINS[c] 'upload') AND NOT (livePhotoFile != '' AND classFile == '\(NKTypeClassFile.video.rawValue)')" + init() { observerToken = NotificationCenter.default.addObserver(forName: UIApplication.didReceiveMemoryWarningNotification, object: nil, queue: nil) { _ in self.cache.removeAll() @@ -36,7 +36,8 @@ final class NCImageCache: @unchecked Sendable { } NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { _ in - self.cache.removeAll() + self.cache.removeAllValues() +// self.cache.removeAll() self.cache = LRUCache(countLimit: self.countLimit) } @@ -63,10 +64,10 @@ final class NCImageCache: @unchecked Sendable { self.isLoadingCache = true self.database.filterAndNormalizeLivePhotos(from: metadatas) { metadatas in autoreleasepool { - self.cache.removeAll() + self.cache.removeAllValues() for metadata in metadatas { guard !isAppInBackground else { - self.cache.removeAll() + self.cache.removeAllValues() break } if let image = self.utility.getImage(ocId: metadata.ocId, @@ -88,10 +89,28 @@ final class NCImageCache: @unchecked Sendable { } deinit { - if let token = observerToken { - NotificationCenter.default.removeObserver(token) - } + NotificationCenter.default.removeObserver(self, name: LRUCacheMemoryWarningNotification, object: nil) + } + + func getMediaMetadatas(account: String, predicate: NSPredicate? = nil) -> ThreadSafeArray? { + guard let tableAccount = NCManageDatabase.shared.getTableAccount(predicate: NSPredicate(format: "account == %@", account)) else { return nil } + let startServerUrl = NCUtilityFileSystem().getHomeServer(urlBase: tableAccount.urlBase, userId: tableAccount.userId) + tableAccount.mediaPath + let predicateBoth = NSPredicate(format: showBothPredicateMediaString, account, startServerUrl) + return NCManageDatabase.shared.getMediaMetadatas(predicate: predicate ?? predicateBoth, sorted: "date") } + + func calculateMaxImages(percentage: Double, imageSizeKB: Double) -> Int { + let totalRamBytes = Double(ProcessInfo.processInfo.physicalMemory) + let cacheSizeBytes = totalRamBytes * (percentage / 100.0) + let imageSizeBytes = imageSizeKB * 1024 + let maxImages = Int(cacheSizeBytes / imageSizeBytes) + + return maxImages + } + +// func getMediaMetadatas(account: String, predicate: NSPredicate? = nil) -> ThreadSafeArray? { +// return NCManageDatabase.shared.getMediaMetadatas(predicate: predicate ?? predicateBoth, sorted: "date") +// } func allowExtensions(ext: String) -> Bool { return allowExtensions.contains(ext) @@ -129,7 +148,8 @@ final class NCImageCache: @unchecked Sendable { } func removeAll() { - cache.removeAll() +// cache.removeAll() + self.cache.removeAllValues() } // MARK: - MEDIA - @@ -192,31 +212,35 @@ final class NCImageCache: @unchecked Sendable { // MARK: - func getImageFile(colors: [UIColor] = [NCBrandColor.shared.iconImageColor2]) -> UIImage { - return utility.loadImage(named: "doc", colors: colors) + return UIImage(named: "file")!.image(color: colors.first!, size: 24) } - func getImageShared(colors: [UIColor] = NCBrandColor.shared.iconImageMultiColors) -> UIImage { - return utility.loadImage(named: "person.fill.badge.plus", colors: colors) + func getImageShared(colors: [UIColor] = [NCBrandColor.shared.iconSystemGrayColor]) -> UIImage { + return utility.loadImage(named: "share", colors: colors, size: 24) } - func getImageCanShare(colors: [UIColor] = NCBrandColor.shared.iconImageMultiColors) -> UIImage { - return utility.loadImage(named: "person.fill.badge.plus", colors: colors) + func getImageCanShare(colors: [UIColor] = [NCBrandColor.shared.iconSystemGrayColor]) -> UIImage { + return utility.loadImage(named: "share", colors: colors, size: 24) } - func getImageShareByLink(colors: [UIColor] = [NCBrandColor.shared.iconImageColor]) -> UIImage { - return utility.loadImage(named: "link", colors: colors) + func getImageShareByLink(colors: [UIColor] = [NCBrandColor.shared.iconSystemGrayColor]) -> UIImage { + return utility.loadImage(named: "share", colors: colors, size: 24) } + func getImageSharedWithMe(colors: [UIColor] = [NCBrandColor.shared.iconSystemGrayColor]) -> UIImage { + return utility.loadImage(named: "cloudUpload", colors: [NCBrandColor.shared.nmcIconSharedWithMe], size: 24) + } + func getImageFavorite(colors: [UIColor] = [NCBrandColor.shared.yellowFavorite]) -> UIImage { - return utility.loadImage(named: "star.fill", colors: colors) + return utility.loadImage(named: "star.fill", colors: colors, size: 24) } func getImageOfflineFlag(colors: [UIColor] = [.systemGreen]) -> UIImage { - return utility.loadImage(named: "arrow.down.circle.fill", colors: colors) + return utility.loadImage(named: "arrow.down.circle.fill", colors: colors, size: 24) } func getImageLocal(colors: [UIColor] = [.systemGreen]) -> UIImage { - return utility.loadImage(named: "checkmark.circle.fill", colors: colors) + return utility.loadImage(named: "checkmark.circle.fill", colors: colors, size: 24) } func getImageCheckedYes(color: UIColor) -> UIImage? { @@ -231,43 +255,51 @@ final class NCImageCache: @unchecked Sendable { return UIImage(systemName: "circle", withConfiguration: config) } + func getImageCheckedYes(colors: [UIColor] = [NCBrandColor.shared.iconImageColor]) -> UIImage { + return UIImage(named: "checkedYes")! + } + + func getImageCheckedNo(colors: [UIColor] = [NCBrandColor.shared.iconImageColor]) -> UIImage { + return utility.loadImage(named: "circle", colors: colors, size: 24) + } + func getImageButtonMore(colors: [UIColor] = [NCBrandColor.shared.iconImageColor]) -> UIImage { - return utility.loadImage(named: "ellipsis", colors: colors) + return UIImage(named: "more")!.image(color: .systemGray, size: 24) } func getImageButtonStop(colors: [UIColor] = [NCBrandColor.shared.iconImageColor]) -> UIImage { - return utility.loadImage(named: "stop.circle", colors: colors) + return utility.loadImage(named: "stop.circle", colors: colors, size: 24) } func getImageButtonMoreLock(colors: [UIColor] = [NCBrandColor.shared.iconImageColor]) -> UIImage { - return utility.loadImage(named: "lock.fill", colors: colors) + return utility.loadImage(named: "lock.fill", colors: colors, size: 24) } func getFolder(account: String) -> UIImage { - return UIImage(named: "folder")!.image(color: NCBrandColor.shared.getElement(account: account)) + return UIImage(named: "folder")! } func getFolderEncrypted(account: String) -> UIImage { - return UIImage(named: "folderEncrypted")!.image(color: NCBrandColor.shared.getElement(account: account)) + return UIImage(named: "folderEncrypted")! } func getFolderSharedWithMe(account: String) -> UIImage { - return UIImage(named: "folder_shared_with_me")!.image(color: NCBrandColor.shared.getElement(account: account)) + return UIImage(named: "folder_shared_with_me")! } func getFolderPublic(account: String) -> UIImage { - return UIImage(named: "folder_public")!.image(color: NCBrandColor.shared.getElement(account: account)) + return UIImage(named: "folder_public")! } func getFolderGroup(account: String) -> UIImage { - return UIImage(named: "folder_group")!.image(color: NCBrandColor.shared.getElement(account: account)) + return UIImage(named: "folder_group")! } func getFolderExternal(account: String) -> UIImage { - return UIImage(named: "folder_external")!.image(color: NCBrandColor.shared.getElement(account: account)) + return UIImage(named: "folder_external")! } func getFolderAutomaticUpload(account: String) -> UIImage { - return UIImage(named: "folderAutomaticUpload")!.image(color: NCBrandColor.shared.getElement(account: account)) + return UIImage(named: "folderAutomaticUpload")! } } diff --git a/iOSClient/Settings/NCPreferences.swift b/iOSClient/Settings/NCPreferences.swift index 387fb10bfd..96ce2c79ee 100644 --- a/iOSClient/Settings/NCPreferences.swift +++ b/iOSClient/Settings/NCPreferences.swift @@ -225,6 +225,36 @@ final class NCPreferences: NSObject { } } + var mediaColumnCount: Int { + get { + let value = getIntPreference(key: "mediaColumnCount", defaultValue: 3) + return value + } + set { + setUserDefaults(newValue, forKey: "mediaColumnCount") + } + } + + var mediaTypeLayout: String { + get { + let value = getStringPreference(key: "mediaTypeLayout", defaultValue: NCGlobal.shared.mediaLayoutRatio) + return value + } + set { + setUserDefaults(newValue, forKey: "mediaTypeLayout") + } + } + + var mediaSortDate: String { + get { + let value = getStringPreference(key: "mediaSortDate", defaultValue: "date") + return value + } + set { + setUserDefaults(newValue, forKey: "mediaSortDate") + } + } + var textRecognitionStatus: Bool { get { return getBoolPreference(key: "textRecognitionStatus", defaultValue: false) @@ -445,10 +475,12 @@ final class NCPreferences: NSObject { } func isEndToEndEnabled(account: String) -> Bool { - guard let certificate = getEndToEndCertificate(account: account), !certificate.isEmpty, + guard let capabilities = NCNetworking.shared.capabilities[account], + let certificate = getEndToEndCertificate(account: account), !certificate.isEmpty, let publicKey = getEndToEndPublicKey(account: account), !publicKey.isEmpty, let privateKey = getEndToEndPrivateKey(account: account), !privateKey.isEmpty, - let passphrase = getEndToEndPassphrase(account: account), !passphrase.isEmpty else { + let passphrase = getEndToEndPassphrase(account: account), !passphrase.isEmpty, + NCGlobal.shared.e2eeVersions.contains(capabilities.e2EEApiVersion) else { return false } return true diff --git a/iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayerToolBar.swift b/iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayerToolBar.swift index 7304ddafbb..6d5e5411d2 100644 --- a/iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayerToolBar.swift +++ b/iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayerToolBar.swift @@ -9,6 +9,7 @@ import UIKit import AVKit import MediaPlayer import MobileVLCKit +import FloatingPanel import Alamofire import LucidBanner @@ -62,11 +63,9 @@ class NCPlayerToolBar: UIView { subtitleButton.setImage(utility.loadImage(named: "captions.bubble", colors: [.white]), for: .normal) subtitleButton.isEnabled = false - subtitleButton.showsMenuAsPrimaryAction = true audioButton.setImage(utility.loadImage(named: "speaker.zzz", colors: [.white]), for: .normal) audioButton.isEnabled = false - audioButton.showsMenuAsPrimaryAction = true if UIDevice.current.userInterfaceIdiom == .pad { pointSize = 60 @@ -144,9 +143,6 @@ class NCPlayerToolBar: UIView { } MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] = position - - setupSubtitleButton() - setupAudioButton() } public func updatePlaybackPosition() { @@ -257,46 +253,20 @@ class NCPlayerToolBar: UIView { viewerMediaPage?.changeScreenMode(mode: viewerMediaScreenMode) } - private func setupSubtitleButton() { + @IBAction func tapSubTitle(_ sender: Any) { guard let player = ncplayer?.player else { return } + let spuTracks = player.videoSubTitlesNames + let spuTrackIndexes = player.videoSubTitlesIndexes - var currentIndex: Int? - if let data = database.getVideo(metadata: metadata), let idx = data.currentVideoSubTitleIndex { - currentIndex = idx - } else { - currentIndex = Int(player.currentVideoSubTitleIndex) - } - - subtitleButton.menu = NCContextMenuPlayerTracks( - trackType: .subtitle, - tracks: player.videoSubTitlesNames, - trackIndexes: player.videoSubTitlesIndexes, - currentIndex: currentIndex, - ncplayer: ncplayer, - metadata: metadata, - viewerMediaPage: viewerMediaPage - ).viewMenu() + toggleMenuSubTitle(spuTracks: spuTracks, spuTrackIndexes: spuTrackIndexes, sender: sender) } - private func setupAudioButton() { + @IBAction func tapAudio(_ sender: Any) { guard let player = ncplayer?.player else { return } + let audioTracks = player.audioTrackNames + let audioTrackIndexes = player.audioTrackIndexes - var currentIndex: Int? - if let data = database.getVideo(metadata: metadata), let idx = data.currentAudioTrackIndex { - currentIndex = idx - } else { - currentIndex = Int(player.currentAudioTrackIndex) - } - - audioButton.menu = NCContextMenuPlayerTracks( - trackType: .audio, - tracks: player.audioTrackNames, - trackIndexes: player.audioTrackIndexes, - currentIndex: currentIndex, - ncplayer: ncplayer, - metadata: metadata, - viewerMediaPage: viewerMediaPage - ).viewMenu() + toggleMenuAudio(audioTracks: audioTracks, audioTrackIndexes: audioTrackIndexes, sender: sender) } @IBAction func tapPlayerPause(_ sender: Any) { @@ -336,8 +306,145 @@ class NCPlayerToolBar: UIView { } } +extension NCPlayerToolBar { + func toggleMenuSubTitle(spuTracks: [Any], spuTrackIndexes: [Any], sender: Any?) { + var actions = [NCMenuAction]() + var subTitleIndex: Int? + + if let data = self.database.getVideo(metadata: metadata), let idx = data.currentVideoSubTitleIndex { + subTitleIndex = idx + } else if let idx = ncplayer?.player.currentVideoSubTitleIndex { + subTitleIndex = Int(idx) + } + + if !spuTracks.isEmpty { + for index in 0...spuTracks.count - 1 { + + guard let title = spuTracks[index] as? String, let idx = spuTrackIndexes[index] as? Int32, let metadata = self.metadata else { return } + + actions.append( + NCMenuAction( + title: title, + icon: UIImage(), + onTitle: title, + onIcon: UIImage(), + selected: (subTitleIndex ?? -9999) == idx, + on: (subTitleIndex ?? -9999) == idx, + sender: sender, + action: { _ in + self.ncplayer?.player.currentVideoSubTitleIndex = idx + self.database.addVideo(metadata: metadata, currentVideoSubTitleIndex: Int(idx)) + } + ) + ) + } + + actions.append(.seperator(order: 0, sender: sender)) + } + + actions.append( + NCMenuAction( + title: NSLocalizedString("_add_subtitle_", comment: ""), + icon: UIImage(), + onTitle: NSLocalizedString("_add_subtitle_", comment: ""), + onIcon: UIImage(), + selected: false, + on: false, + sender: sender, + action: { _ in + + guard let metadata = self.metadata else { return } + let storyboard = UIStoryboard(name: "NCSelect", bundle: nil) + if let navigationController = storyboard.instantiateInitialViewController() as? UINavigationController, + let viewController = navigationController.topViewController as? NCSelect { + + viewController.delegate = self + viewController.typeOfCommandView = .nothing + viewController.includeDirectoryE2EEncryption = false + viewController.enableSelectFile = true + viewController.type = "subtitle" + viewController.serverUrl = metadata.serverUrl + viewController.session = NCSession.shared.getSession(account: metadata.account) + viewController.controller = nil + + self.viewerMediaPage?.present(navigationController, animated: true, completion: nil) + } + } + ) + ) + + viewerMediaPage?.presentMenu(with: actions, menuColor: UIColor(hexString: "#1C1C1EFF"), textColor: .white, sender: sender) + } + + func toggleMenuAudio(audioTracks: [Any], audioTrackIndexes: [Any], sender: Any?) { + var actions = [NCMenuAction]() + var audioIndex: Int? + + if let data = self.database.getVideo(metadata: metadata), let idx = data.currentAudioTrackIndex { + audioIndex = idx + } else if let idx = ncplayer?.player.currentAudioTrackIndex { + audioIndex = Int(idx) + } + + if !audioTracks.isEmpty { + for index in 0...audioTracks.count - 1 { + guard let title = audioTracks[index] as? String, let idx = audioTrackIndexes[index] as? Int32, let metadata = self.metadata else { return } + actions.append( + NCMenuAction( + title: title, + icon: UIImage(), + onTitle: title, + onIcon: UIImage(), + selected: (audioIndex ?? -9999) == idx, + on: (audioIndex ?? -9999) == idx, + sender: sender, + action: { _ in + self.ncplayer?.player.currentAudioTrackIndex = idx + self.database.addVideo(metadata: metadata, currentAudioTrackIndex: Int(idx)) + } + ) + ) + } + + actions.append(.seperator(order: 0, sender: sender)) + } + + actions.append( + NCMenuAction( + title: NSLocalizedString("_add_audio_", comment: ""), + icon: UIImage(), + onTitle: NSLocalizedString("_add_audio_", comment: ""), + onIcon: UIImage(), + selected: false, + on: false, + sender: sender, + action: { _ in + guard let metadata = self.metadata else { return } + let storyboard = UIStoryboard(name: "NCSelect", bundle: nil) + if let navigationController = storyboard.instantiateInitialViewController() as? UINavigationController, + let viewController = navigationController.topViewController as? NCSelect { + + viewController.delegate = self + viewController.typeOfCommandView = .nothing + viewController.includeDirectoryE2EEncryption = false + viewController.enableSelectFile = true + viewController.type = "audio" + viewController.serverUrl = metadata.serverUrl + viewController.session = NCSession.shared.getSession(account: metadata.account) + viewController.controller = nil + + self.viewerMediaPage?.present(navigationController, animated: true, completion: nil) + } + } + ) + ) + + viewerMediaPage?.presentMenu(with: actions, menuColor: UIColor(hexString: "#1C1C1EFF"), textColor: .white, sender: sender) + } +} + extension NCPlayerToolBar: NCSelectDelegate { - func dismissSelect(serverUrl: String?, metadata: tableMetadata?, type: String, items: [Any], overwrite: Bool, copy: Bool, move: Bool, session: NCSession.Session, controller: NCMainTabBarController?) { + func dismissSelect(serverUrl: String?, metadata: tableMetadata?, type: String, items: [Any], overwrite: Bool, copy: Bool, move: Bool, session: NCSession.Session) { if let metadata = metadata, let viewerMediaPage = viewerMediaPage { let fileNameLocalPath = NCUtilityFileSystem().getDirectoryProviderStorageOcId(metadata.ocId, fileName: metadata.fileNameView, userId: metadata.userId, urlBase: metadata.urlBase) let windowScene = SceneManager.shared.getWindowScene(controller: viewerMediaPage.tabBarController) @@ -373,17 +480,13 @@ extension NCPlayerToolBar: NCSelectDelegate { banner?.update(payload: LucidBannerPayload.Update(progress: Double(progress.fractionCompleted)), for: token) } - }) { _, response, error in + }) { _, etag, _, _, _, _, error in Task { if let banner { banner.dismiss() } let ocId = metadata.ocId - let allHeaderFields = response?.response?.allHeaderFields - let nkComm = NextcloudKit.shared.nkCommonInstance - let etag = nkComm.normalizedETag(nkComm.findHeader("oc-etag", allHeaderFields: allHeaderFields)) - await self.database.setMetadataSessionAsync(ocId: ocId, session: "", sessionTaskIdentifier: 0, diff --git a/iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayerToolBar.xib b/iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayerToolBar.xib index deaf0d7558..172f9daf67 100644 --- a/iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayerToolBar.xib +++ b/iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayerToolBar.xib @@ -1,8 +1,9 @@ - + - + + @@ -20,11 +21,17 @@ + + +