diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index f69a42401b..2b684f248d 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -586,6 +586,8 @@ F77444F622281649000D5EB0 /* NCMediaCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = F77444F422281649000D5EB0 /* NCMediaCell.xib */; }; F77444F8222816D5000D5EB0 /* NCPickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F77444F7222816D5000D5EB0 /* NCPickerViewController.swift */; }; F778231E2C42C07C001BB94F /* NCCollectionViewCommon+MediaLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F778231D2C42C07C001BB94F /* NCCollectionViewCommon+MediaLayout.swift */; }; + F77A6B30301112C5007103E6 /* NCMediaSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F77A6B2D301112C5007103E6 /* NCMediaSectionHeader.swift */; }; + F77A6B31301112C5007103E6 /* NCMediaSectionHeader.xib in Resources */ = {isa = PBXBuildFile; fileRef = F77A6B2E301112C5007103E6 /* NCMediaSectionHeader.xib */; }; F77B0F631D118A16002130FE /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = F7E70DE91A24DE4100E1B66A /* Localizable.strings */; }; F77B0F7D1D118A16002130FE /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F7F67BB81A24D27800EE80DA /* Images.xcassets */; }; F77B0F893008ABDC00EE4AE1 /* NCBlurEffectsTestView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F77B0F883008ABDC00EE4AE1 /* NCBlurEffectsTestView.swift */; }; @@ -759,6 +761,7 @@ F7BAADCC1ED5A87C00B7EAD4 /* NCManageDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7BAADB51ED5A87C00B7EAD4 /* NCManageDatabase.swift */; }; F7BC287E26663F6C004D46C5 /* NCViewCertificateDetails.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F7BC287D26663F6C004D46C5 /* NCViewCertificateDetails.storyboard */; }; F7BC288026663F85004D46C5 /* NCViewCertificateDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7BC287F26663F85004D46C5 /* NCViewCertificateDetails.swift */; }; + F7BCE48E301091EA00876F4D /* NCMediaDatePickerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7BCE48D301091E400876F4D /* NCMediaDatePickerViewController.swift */; }; F7BD0A002C468925003A4A6D /* NCMedia+CollectionViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7BD09FF2C468925003A4A6D /* NCMedia+CollectionViewDataSource.swift */; }; F7BD0A022C4689A4003A4A6D /* NCMedia+CollectionViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7BD0A012C4689A4003A4A6D /* NCMedia+CollectionViewDelegate.swift */; }; F7BD0A042C4689E9003A4A6D /* NCMedia+MediaLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7BD0A032C4689E9003A4A6D /* NCMedia+MediaLayout.swift */; }; @@ -1626,6 +1629,8 @@ F77444F422281649000D5EB0 /* NCMediaCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = NCMediaCell.xib; sourceTree = ""; }; F77444F7222816D5000D5EB0 /* NCPickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCPickerViewController.swift; sourceTree = ""; }; F778231D2C42C07C001BB94F /* NCCollectionViewCommon+MediaLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCCollectionViewCommon+MediaLayout.swift"; sourceTree = ""; }; + F77A6B2D301112C5007103E6 /* NCMediaSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaSectionHeader.swift; sourceTree = ""; }; + F77A6B2E301112C5007103E6 /* NCMediaSectionHeader.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = NCMediaSectionHeader.xib; sourceTree = ""; }; F77B0F883008ABDC00EE4AE1 /* NCBlurEffectsTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCBlurEffectsTestView.swift; sourceTree = ""; }; F77BB745289984CA0090FC19 /* UIViewController+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIViewController+Extension.swift"; sourceTree = ""; }; F77BB747289985270090FC19 /* UITabBarController+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITabBarController+Extension.swift"; sourceTree = ""; }; @@ -1762,6 +1767,7 @@ F7BB04851FD58ACB00BBFD2A /* cs-CZ */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "cs-CZ"; path = "cs-CZ.lproj/Localizable.strings"; sourceTree = ""; }; F7BC287D26663F6C004D46C5 /* NCViewCertificateDetails.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = NCViewCertificateDetails.storyboard; sourceTree = ""; }; F7BC287F26663F85004D46C5 /* NCViewCertificateDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCViewCertificateDetails.swift; sourceTree = ""; }; + F7BCE48D301091E400876F4D /* NCMediaDatePickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaDatePickerViewController.swift; sourceTree = ""; }; F7BD09FF2C468925003A4A6D /* NCMedia+CollectionViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCMedia+CollectionViewDataSource.swift"; sourceTree = ""; }; F7BD0A012C4689A4003A4A6D /* NCMedia+CollectionViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCMedia+CollectionViewDelegate.swift"; sourceTree = ""; }; F7BD0A032C4689E9003A4A6D /* NCMedia+MediaLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCMedia+MediaLayout.swift"; sourceTree = ""; }; @@ -2904,6 +2910,15 @@ path = Files; sourceTree = ""; }; + F77A6B2F301112C5007103E6 /* Section header */ = { + isa = PBXGroup; + children = ( + F77A6B2D301112C5007103E6 /* NCMediaSectionHeader.swift */, + F77A6B2E301112C5007103E6 /* NCMediaSectionHeader.xib */, + ); + path = "Section header"; + sourceTree = ""; + }; F77B0F8A3008ACAD00EE4AE1 /* Test */ = { isa = PBXGroup; children = ( @@ -3444,6 +3459,7 @@ isa = PBXGroup; children = ( F720B5B72507B9A5008C94E5 /* Cell */, + F77A6B2F301112C5007103E6 /* Section header */, F7501C302212E57400FB1415 /* NCMedia.storyboard */, F7501C312212E57400FB1415 /* NCMedia.swift */, F7BD09FF2C468925003A4A6D /* NCMedia+CollectionViewDataSource.swift */, @@ -3456,6 +3472,7 @@ F78B87E62B62527100C65ADC /* NCMediaDataSource.swift */, F755CB3F2B8CB13C00CE27E9 /* NCMediaLayout.swift */, F79699E62E689F68000EC82A /* NCMediaNavigationController.swift */, + F7BCE48D301091E400876F4D /* NCMediaDatePickerViewController.swift */, F71CFA662F2A07C6007A3AE9 /* NCMediaNetwork.swift */, F7D60CAE2C941ACB008FBFDD /* NCMediaPinchGesture.swift */, F741C2232B6B9FD600E849BB /* NCMediaSelectTabBar.swift */, @@ -4275,6 +4292,7 @@ F768822D2C0DD1E7001CF441 /* Acknowledgements.rtf in Resources */, F76D3CF32428B94E005DFA87 /* NCViewerPDFSearchCell.xib in Resources */, F717402D24F699A5000C87D5 /* NCFavorite.storyboard in Resources */, + F77A6B31301112C5007103E6 /* NCMediaSectionHeader.xib in Resources */, F723B3DD22FC6D1D00301EFE /* NCShareCommentsCell.xib in Resources */, F78ACD4B21903F850088454D /* NCTrashListCell.xib in Resources */, AF93471927E2361E002537EE /* NCShareAdvancePermissionFooter.xib in Resources */, @@ -4909,6 +4927,7 @@ F785EE9D246196DF00B3F945 /* NCNetworkingE2EE.swift in Sources */, F724377B2C10B83E00C7C68D /* NCSharePermissions.swift in Sources */, F317C82E2E844C5300761AEA /* ClientIntegrationUIViewer.swift in Sources */, + F77A6B30301112C5007103E6 /* NCMediaSectionHeader.swift in Sources */, F794E13D2BBBFF2E003693D7 /* NCMainTabBarController.swift in Sources */, F7CBC1252BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.swift in Sources */, F7D4BF3D2CA2E8D800A5E746 /* TOPasscodeKeypadView.m in Sources */, @@ -5089,6 +5108,7 @@ F7635D8D2FB1F820007F658D /* NCVideoVLCPresenter.swift in Sources */, F749ED312FADD62600CE8DFA /* NCMediaViewerDetailView.swift in Sources */, F768822A2C0DD1E7001CF441 /* NCSettingsModel.swift in Sources */, + F7BCE48E301091EA00876F4D /* NCMediaDatePickerViewController.swift in Sources */, F7A98A522FC97464009E6313 /* NCVideoViewerContentView+AVPlayer.swift in Sources */, F7103F652FD6A8F800C6C8F1 /* NCMediaViewerThumbnail.swift in Sources */, F737DA9D2B7B893C0063BAFC /* NCPasscode.swift in Sources */, diff --git a/iOSClient/Data/NCManageDatabase+Metadata.swift b/iOSClient/Data/NCManageDatabase+Metadata.swift index 98e4c0cd65..5b9bba5ac0 100644 --- a/iOSClient/Data/NCManageDatabase+Metadata.swift +++ b/iOSClient/Data/NCManageDatabase+Metadata.swift @@ -1575,6 +1575,63 @@ extension NCManageDatabase { } ?? [] } +#if !EXTENSION + func getMediaCompactMetadatasAsync( + predicate: NSPredicate, + sortedByKeyPath: String, + ascending: Bool = false + ) async -> [NCMediaDataSource.NCCompactMetadata] { + await core.performRealmReadAsync { realm in + let results = realm.objects(tableMetadata.self) + .filter(predicate) + .sorted( + byKeyPath: sortedByKeyPath, + ascending: ascending + ) + + let allFileIds = Set(results.map(\.fileId)) + + var compactMetadatas: [NCMediaDataSource.NCCompactMetadata] = [] + compactMetadatas.reserveCapacity(results.count) + + for metadata in results { + let linkedFileId = metadata.livePhotoFile + let hasLivePhotoLink = !linkedFileId.isEmpty + let linkedTargetExists = allFileIds.contains(linkedFileId) + + let isImage = metadata.classFile == NKTypeClassFile.image.rawValue + let isVideo = metadata.classFile == NKTypeClassFile.video.rawValue + + if isVideo && hasLivePhotoLink { + // Remove Live Photo videos, including orphaned ones. + continue + } + + let isLivePhoto = isImage && + hasLivePhotoLink && + linkedTargetExists + + compactMetadatas.append( + NCMediaDataSource.NCCompactMetadata( + date: metadata.date as Date, + etag: metadata.etag, + imageSize: CGSize( + width: metadata.width, + height: metadata.height + ), + isImage: isImage, + isLivePhoto: isLivePhoto, + isVideo: isVideo, + ocId: metadata.ocId + ) + ) + } + + return compactMetadatas + } ?? [] + } +#endif + // MARK: - helpers /// Extracts the relative DAV folder path and filename from metadata. diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommon+SyncMetadata.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon+SyncMetadata.swift index 5e0254858d..eec861c662 100644 --- a/iOSClient/Main/Collection Common/NCCollectionViewCommon+SyncMetadata.swift +++ b/iOSClient/Main/Collection Common/NCCollectionViewCommon+SyncMetadata.swift @@ -64,7 +64,7 @@ extension NCCollectionViewCommon { /// all tracked `URLSessionTask` are cancelled on any exit path via `defer`. /// /// - Parameter metadatas: The list of `tableMetadata` entries to scan and refresh. - func networkSyncMetadata(metadatas: [tableMetadata]) async { + private func networkSyncMetadata(metadatas: [tableMetadata]) async { // Order by date (descending) let metadatas = metadatas.sorted { ($0.date as Date) > ($1.date as Date) diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift index 89cd190bde..1fde191f4f 100644 --- a/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift +++ b/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift @@ -92,7 +92,7 @@ class NCCollectionViewCommon: UIViewController, NCAccountSettingsModelDelegate, internal var currentScale: CGFloat = 1.0 internal var maxColumns: Int { let screenWidth = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height) - let column = Int(screenWidth / 44) + let column = Int(screenWidth / 55) return column } diff --git a/iOSClient/Media/NCMedia+CollectionViewDataSource.swift b/iOSClient/Media/NCMedia+CollectionViewDataSource.swift index e07bbe3edf..cb21e6bae8 100644 --- a/iOSClient/Media/NCMedia+CollectionViewDataSource.swift +++ b/iOSClient/Media/NCMedia+CollectionViewDataSource.swift @@ -9,29 +9,111 @@ import RealmSwift extension NCMedia: UICollectionViewDataSource { func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { if kind == mediaSectionHeader { - guard let header = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "sectionFirstHeaderEmptyData", for: indexPath) as? NCSectionFirstHeaderEmptyData else { return NCSectionFirstHeaderEmptyData() } - header.emptyImage.image = utility.loadImage(named: "photo", colors: [NCBrandColor.shared.getElement(account: session.account)]) - if self.searchMediaInProgress { - header.emptyTitle.text = NSLocalizedString("_search_in_progress_", comment: "") + if dataSource.isEmpty() { + guard let header = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: "sectionFirstHeaderEmptyData", + for: indexPath + ) as? NCSectionFirstHeaderEmptyData else { + return NCSectionFirstHeaderEmptyData() + } + + header.emptyImage.isHidden = false + header.emptyDescription.isHidden = false + + header.emptyImage.image = utility.loadImage( + named: "photo", + colors: [ + NCBrandColor.shared.getElement( + account: session.account + ) + ] + ) + + if searchMediaInProgress { + header.emptyTitle.text = NSLocalizedString( + "_search_in_progress_", + comment: "" + ) + } else { + header.emptyTitle.text = NSLocalizedString( + "_tutorial_photo_view_", + comment: "" + ) + } + + header.emptyDescription.text = "" + + return header + } + + guard let header = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: "sectionHeader", + for: indexPath + ) as? NCMediaSectionHeader else { + return NCMediaSectionHeader() + } + + guard let yearMonth = dataSource.yearMonth(for: indexPath.section) else { + header.titleLabel.text = nil + return header + } + + var components = DateComponents() + components.year = yearMonth.year + components.month = yearMonth.month + components.day = 1 + + if let date = Calendar.current.date(from: components) { + header.titleLabel.text = date.formatted( + .dateTime + .month(.wide) + .year() + ) } else { - header.emptyTitle.text = NSLocalizedString("_tutorial_photo_view_", comment: "") + header.titleLabel.text = "\(yearMonth.month)/\(yearMonth.year)" } - header.emptyDescription.text = "" + return header - } else { - guard let footer = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "sectionFooter", for: indexPath) as? NCSectionFooter else { return NCSectionFooter() } - let images = dataSource.compactMetadatas.filter({ $0.isImage }).count - let video = dataSource.compactMetadatas.count - images + } - footer.setTitleLabel("\(images) " + NSLocalizedString("_images_", comment: "") + " • " + "\(video) " + NSLocalizedString("_video_", comment: "")) + guard let footer = collectionView.dequeueReusableSupplementaryView( + ofKind: kind, + withReuseIdentifier: "sectionFooter", + for: indexPath + ) as? NCSectionFooter else { + return NCSectionFooter() + } + + guard indexPath.section == dataSource.numberOfSections - 1 else { + footer.setTitleLabel("") return footer } + + let images = dataSource.compactMetadatas.filter(\.isImage).count + let videos = dataSource.compactMetadatas.count - images + + footer.setTitleLabel( + "\(images) " + + NSLocalizedString("_images_", comment: "") + + " • " + + "\(videos) " + + NSLocalizedString("_video_", comment: "") + ) + + return footer } func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - let numberOfItemsInSection = dataSource.compactMetadatas.count - self.numberOfColumns = getColumnCount() - return numberOfItemsInSection + guard !dataSource.isEmpty() else { + return 0 + } + return dataSource.numberOfItems(in: section) + } + + func numberOfSections(in collectionView: UICollectionView) -> Int { + dataSource.isEmpty() ? 1 : dataSource.numberOfSections } func collectionView(_ collectionView: UICollectionView, @@ -65,27 +147,33 @@ extension NCMedia: UICollectionViewDataSource { identifier: ocId, priority: .visible ) { - guard let metadata = await NCManageDatabase.shared.getMetadataFromOcIdAsync(ocId) else { + guard var metadata = await NCManageDatabase.shared.getMetadataFromOcIdAsync(ocId) else { return } - let iconName = metadata.iconName - let account = metadata.account - // Retrieves and stores complete metadata when the media record is a placeholder. if metadata.placeholder { - let result = await self.networking.readFileAsync(serverUrlFileName: metadata.serverUrlFileName, account: metadata.account) + let result = await self.networking.readFileAsync( + serverUrlFileName: metadata.serverUrlFileName, + account: metadata.account + ) + guard !Task.isCancelled, result.error == .success, - let metadata = result.metadata else { + let hydratedMetadata = result.metadata else { return } - await self.database.addMetadataAsync(metadata) + + await self.database.addMetadataAsync(hydratedMetadata) + metadata = hydratedMetadata } + let iconName = metadata.iconName + let account = metadata.account + let result = await NextcloudKit.shared.downloadPreviewAsync( fileId: metadata.fileId, etag: metadata.etag, - account: account + account: metadata.account ) guard !Task.isCancelled, diff --git a/iOSClient/Media/NCMedia+CollectionViewDelegate.swift b/iOSClient/Media/NCMedia+CollectionViewDelegate.swift index a3d8ed59f1..39f4564b92 100644 --- a/iOSClient/Media/NCMedia+CollectionViewDelegate.swift +++ b/iOSClient/Media/NCMedia+CollectionViewDelegate.swift @@ -8,35 +8,79 @@ import RealmSwift extension NCMedia: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - Task { - guard let compactMetadata = dataSource.getCompactMetadata(indexPath: indexPath), - let cell = collectionView.cellForItem(at: indexPath) as? NCMediaCell else { return } + Task { @MainActor in + guard let compactMetadata = dataSource.getCompactMetadata(indexPath: indexPath) else { + return + } if isEditMode { + guard let cell = collectionView.cellForItem(at: indexPath) as? NCMediaCell else { + return + } + if let index = fileSelect.firstIndex(of: compactMetadata.ocId) { fileSelect.remove(at: index) - cell.selected(false, color: NCBrandColor.shared.getElement(account: session.account)) + cell.selected( + false, + color: NCBrandColor.shared.getElement(account: session.account) + ) } else { fileSelect.append(compactMetadata.ocId) - cell.selected(true, color: NCBrandColor.shared.getElement(account: session.account)) + cell.selected( + true, + color: NCBrandColor.shared.getElement(account: session.account) + ) } + tabBarSelect.selectCount = fileSelect.count - } else if let metadata = await self.database.getMetadataFromOcIdAsync(compactMetadata.ocId) { - let image = utility.getImage(ocId: metadata.ocId, etag: metadata.etag, ext: global.previewExt1024, userId: metadata.userId, urlBase: metadata.urlBase) - var viewerTransitionSource: NCMediaViewerTransitionSource? - let ocIds = dataSource.compactMetadatas.map { $0.ocId } - - if let imageView = cell.image, - let image = imageView.image, - let window = imageView.window { - let sourceFrame = imageView.convert(imageView.bounds, to: window) - viewerTransitionSource = NCMediaViewerTransitionSource(image: image, sourceFrame: sourceFrame, cornerRadius: imageView.layer.cornerRadius) - } + return + } - if let vc = await NCViewer().getViewerController(metadata: metadata, ocIds: ocIds, image: image, delegate: self, viewerTransitionSource: viewerTransitionSource) { - vc.view.backgroundColor = .clear - self.navigationController?.pushViewController(vc, animated: false) - } + guard let metadata = await database.getMetadataFromOcIdAsync(compactMetadata.ocId), + dataSource.getCompactMetadata(indexPath: indexPath)?.ocId == compactMetadata.ocId else { + return + } + + let image = utility.getImage( + ocId: metadata.ocId, + etag: metadata.etag, + ext: global.previewExt1024, + userId: metadata.userId, + urlBase: metadata.urlBase + ) + + var viewerTransitionSource: NCMediaViewerTransitionSource? + + if let cell = collectionView.cellForItem(at: indexPath) as? NCMediaCell, + let imageView = cell.image, + let transitionImage = imageView.image, + let window = imageView.window { + let sourceFrame = imageView.convert( + imageView.bounds, + to: window + ) + + viewerTransitionSource = NCMediaViewerTransitionSource( + image: transitionImage, + sourceFrame: sourceFrame, + cornerRadius: imageView.layer.cornerRadius + ) + } + + let ocIds = dataSource.allOcIds + + if let viewController = await NCViewer().getViewerController( + metadata: metadata, + ocIds: ocIds, + image: image, + delegate: self, + viewerTransitionSource: viewerTransitionSource + ) { + viewController.view.backgroundColor = .clear + navigationController?.pushViewController( + viewController, + animated: false + ) } } } diff --git a/iOSClient/Media/NCMedia+Command.swift b/iOSClient/Media/NCMedia+Command.swift index a1c3fc07f6..4f1c71675f 100644 --- a/iOSClient/Media/NCMedia+Command.swift +++ b/iOSClient/Media/NCMedia+Command.swift @@ -33,45 +33,179 @@ extension NCMedia { } func setTitleDate() { - if let layoutAttributes = collectionView.collectionViewLayout.layoutAttributesForElements(in: collectionView.bounds) { - let sortedAttributes = layoutAttributes.sorted { $0.frame.minY < $1.frame.minY || ($0.frame.minY == $1.frame.minY && $0.frame.minX < $1.frame.minX) } - - if let firstAttribute = sortedAttributes.first, let metadata = dataSource.getCompactMetadata(indexPath: firstAttribute.indexPath) { - titleDate?.text = utility.getTitleFromDate(metadata.date) - return + let visibleIndexPaths = collectionView.indexPathsForVisibleItems.sorted { + if $0.section == $1.section { + return $0.item < $1.item } + return $0.section < $1.section } - titleDate?.text = "" - } + guard let firstIndexPath = visibleIndexPaths.first, + let lastIndexPath = visibleIndexPaths.last, + let firstMetadata = dataSource.getCompactMetadata(indexPath: firstIndexPath), + let lastMetadata = dataSource.getCompactMetadata(indexPath: lastIndexPath) else { + updateLeftBarButtonItems(date: nil) + return + } - func setElements() { - let highTextTitle = titleDate.frame.height - let isOver = self.collectionView.contentOffset.y + highTextTitle <= -view.safeAreaInsets.top && self.collectionView.contentOffset.y != -view.safeAreaInsets.top + let firstDate = firstMetadata.date + let lastDate = lastMetadata.date + let calendar = Calendar.current - if isOver || dataSource.compactMetadatas.isEmpty { - UIView.animate(withDuration: 0.3) { [self] in - gradientView.isHidden = true - titleDate?.textColor = NCBrandColor.shared.textColor - activityIndicator.color = NCBrandColor.shared.textColor + let firstYear = calendar.component(.year, from: firstDate) + let lastYear = calendar.component(.year, from: lastDate) - if #unavailable(iOS 26.0) { - (self.navigationController as? NCMediaNavigationController)?.updateRightBarButtonsTint(to: NCBrandColor.shared.textColor) - } - } + let title: String + + if calendar.isDate(firstDate, inSameDayAs: lastDate) { + title = firstDate.formatted( + .dateTime + .day() + .month(.abbreviated) + .year() + ) + } else if firstYear == lastYear { + let firstDateTitle = firstDate.formatted( + .dateTime + .day() + .month(.abbreviated) + ) + + let lastDateTitle = lastDate.formatted( + .dateTime + .day() + .month(.abbreviated) + .year() + ) + + title = "\(firstDateTitle) – \(lastDateTitle)" } else { - UIView.animate(withDuration: 0.3) { [self] in - gradientView.isHidden = false - titleDate?.textColor = .white - activityIndicator.color = .white + let firstDateTitle = firstDate.formatted( + .dateTime + .day() + .month(.abbreviated) + .year() + ) - if #unavailable(iOS 26.0) { - (self.navigationController as? NCMediaNavigationController)?.updateRightBarButtonsTint(to: .white) + let lastDateTitle = lastDate.formatted( + .dateTime + .day() + .month(.abbreviated) + .year() + ) + + title = "\(firstDateTitle) – \(lastDateTitle)" + } + + buttonDateBarItem.title = title + + if navigationItem.leftBarButtonItem !== buttonDateBarItem { + updateLeftBarButtonItems(date: buttonDateBarItem) + } + } + + @objc func presentMediaDatePicker() { + let viewController = NCMediaDatePickerViewController( + availableYearMonths: dataSource.availableYearMonths, + selectedYearMonth: currentVisibleYearMonth() + ) + + viewController.onDateSelected = { [weak self] yearMonth in + self?.scrollToMedia(year: yearMonth.year, month: yearMonth.month) + } + + if let sheet = viewController.sheetPresentationController { + sheet.detents = [ + .custom(identifier: .init("mediaDatePicker")) { _ in + 200 } + ] + sheet.prefersGrabberVisible = true + sheet.prefersScrollingExpandsWhenScrolledToEdge = false + } + + present(viewController, animated: true) + } + + private func currentVisibleYearMonth() -> NCYearMonth? { + let firstIndexPath = collectionView.indexPathsForVisibleItems.min { + if $0.section == $1.section { + return $0.item < $1.item } + + return $0.section < $1.section } + + guard let firstIndexPath, + let metadata = dataSource.getCompactMetadata(indexPath: firstIndexPath) else { + return nil + } + + return NCYearMonth(date: metadata.date) + } + + private func scrollToMedia(year: Int, month: Int) { + guard let indexPath = dataSource.firstIndexPath(year: year, month: month) else { + return + } + + collectionView.layoutIfNeeded() + + let sectionIndexPath = IndexPath(item: 0, section: indexPath.section) + + if let attributes = collectionView.collectionViewLayout.layoutAttributesForSupplementaryView( + ofKind: mediaSectionHeader, + at: sectionIndexPath + ) { + let spacingBelowNavigationBar: CGFloat = 16 + + let targetOffsetY = max( + -collectionView.adjustedContentInset.top, + attributes.frame.minY + - collectionView.adjustedContentInset.top + - spacingBelowNavigationBar + ) + + collectionView.setContentOffset( + CGPoint(x: collectionView.contentOffset.x, y: targetOffsetY), + animated: false + ) + } else if let attributes = collectionView.collectionViewLayout.layoutAttributesForItem(at: indexPath) { + // Fallback + let targetOffsetY = attributes.frame.minY - collectionView.adjustedContentInset.top + + collectionView.setContentOffset( + CGPoint(x: collectionView.contentOffset.x, y: targetOffsetY), + animated: false + ) + } + + collectionView.layoutIfNeeded() setTitleDate() } + + @MainActor + func updateLeftBarButtonItems(date: UIBarButtonItem?, activity: Bool? = nil) { + let isActivityVisible = activity ?? searchActivityIndicator.isAnimating + + var items: [UIBarButtonItem] = [] + + if let date { + items.append(date) + } + + if isActivityVisible { + searchActivityIndicator.startAnimating() + items.append(searchActivityBarButtonItem) + } else { + searchActivityIndicator.stopAnimating() + } + + navigationItem.leftBarButtonItems = items.isEmpty ? nil : items + if items.isEmpty { + collectionView.reloadData() + } + } } extension NCMedia: NCMediaSelectTabBarDelegate { @@ -154,15 +288,8 @@ extension NCMedia: NCMediaSelectTabBarDelegate { await self.database.deleteMetadataAsync(id: ocId) await MainActor.run { - if let indexPath = self.dataSource.indexPath(forOcId: ocId) { - self.collectionView.performBatchUpdates { - self.dataSource.removeCompactMetadata([ocId]) - self.collectionView.deleteItems(at: [indexPath]) - } - } else { - self.dataSource.removeCompactMetadata([ocId]) - self.collectionViewReloadData() - } + self.dataSource.removeCompactMetadata([ocId]) + self.collectionViewReloadData() } } } diff --git a/iOSClient/Media/NCMedia+MediaLayout.swift b/iOSClient/Media/NCMedia+MediaLayout.swift index 9422822684..f574c9409b 100644 --- a/iOSClient/Media/NCMedia+MediaLayout.swift +++ b/iOSClient/Media/NCMedia+MediaLayout.swift @@ -24,19 +24,21 @@ extension NCMedia: NCMediaLayoutDelegate { } func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, heightForHeaderInSection section: Int) -> Float { - var height: Double = 0 - if dataSource.compactMetadatas.count == 0 { - height = utility.getHeightHeaderEmptyData(view: view, portraitOffset: 0, landscapeOffset: -20) - } - return Float(height) + if dataSource.isEmpty() { + return section == 0 + ? Float(utility.getHeightHeaderEmptyData(view: view, portraitOffset: 0, landscapeOffset: -20)) + : .zero + } + + return 80.0 } func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, heightForFooterInSection section: Int) -> Float { - if dataSource.compactMetadatas.count == 0 { + guard !dataSource.isEmpty() else { return .zero - } else { - return 100.0 } + + return section == dataSource.numberOfSections - 1 ? 100.0 : .zero } func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, insetForSection section: Int) -> UIEdgeInsets { @@ -56,16 +58,20 @@ extension NCMedia: NCMediaLayoutDelegate { } func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, sizeForItemAtIndexPath indexPath: IndexPath, columnCount: Int, typeLayout: String) -> CGSize { - if typeLayout == global.mediaLayoutSquare { - return CGSize(width: collectionView.frame.width / CGFloat(columnCount), height: collectionView.frame.width / CGFloat(columnCount)) - } else { - guard let compactMetadata = dataSource.getCompactMetadata(indexPath: indexPath) else { return .zero } + let defaultSize = CGSize( + width: collectionView.bounds.width / CGFloat(columnCount), + height: collectionView.bounds.width / CGFloat(columnCount) + ) - if compactMetadata.imageSize != CGSize.zero { - return compactMetadata.imageSize - } else { - return CGSize(width: collectionView.frame.width / CGFloat(columnCount), height: collectionView.frame.width / CGFloat(columnCount)) - } + guard typeLayout != global.mediaLayoutSquare else { + return defaultSize + } + + guard let compactMetadata = dataSource.getCompactMetadata(indexPath: indexPath), + compactMetadata.imageSize != .zero else { + return defaultSize } + + return compactMetadata.imageSize } } diff --git a/iOSClient/Media/NCMedia+TransferDelegate.swift b/iOSClient/Media/NCMedia+TransferDelegate.swift index 0554d4331d..48cf6c8aaa 100644 --- a/iOSClient/Media/NCMedia+TransferDelegate.swift +++ b/iOSClient/Media/NCMedia+TransferDelegate.swift @@ -10,9 +10,28 @@ import NextcloudKit extension NCMedia: NCTransferDelegate { func transferReloadData(serverUrl: String?) { - Task { - await self.debouncerSearch.call { + Task { [weak self] in + guard let self else { + return + } + + await self.debouncerLoadDataSource.call { await self.loadDataSource() + } + } + + searchMediaTask?.cancel() + + searchMediaTask = Task { [weak self] in + guard let self else { + return + } + + await self.debouncerSearch.call { + guard !Task.isCancelled else { + return + } + await self.searchMediaUI() } } diff --git a/iOSClient/Media/NCMedia.storyboard b/iOSClient/Media/NCMedia.storyboard index fcdb7e83f1..f43f78dea1 100644 --- a/iOSClient/Media/NCMedia.storyboard +++ b/iOSClient/Media/NCMedia.storyboard @@ -1,8 +1,8 @@ - + - + @@ -31,44 +31,17 @@ - - - - - - - - - - - - - - - - - - - diff --git a/iOSClient/Media/NCMedia.swift b/iOSClient/Media/NCMedia.swift index 4f2dfc9721..a703695056 100644 --- a/iOSClient/Media/NCMedia.swift +++ b/iOSClient/Media/NCMedia.swift @@ -9,9 +9,6 @@ import RealmSwift class NCMedia: UIViewController { @IBOutlet weak var collectionView: UICollectionView! - @IBOutlet weak var titleDate: UILabel! - @IBOutlet weak var activityIndicator: UIActivityIndicatorView! - @IBOutlet weak var gradientView: UIView! let layout = NCMediaLayout() let gradientLayer = CAGradientLayer() @@ -27,7 +24,6 @@ class NCMedia: UIViewController { var dataSource = NCMediaDataSource() var isEditMode = false var fileSelect: [String] = [] - var searchMediaInProgress: Bool = false var attributesZoomIn: UIMenuElement.Attributes = [] var attributesZoomOut: UIMenuElement.Attributes = [] var showOnlyImages = false @@ -44,7 +40,7 @@ class NCMedia: UIViewController { var currentScale: CGFloat = 1.0 var maxColumns: Int { let screenWidth = min(UIScreen.main.bounds.width, UIScreen.main.bounds.height) - let column = Int(screenWidth / 44) + let column = Int(screenWidth / 55) return column } @@ -61,6 +57,39 @@ class NCMedia: UIViewController { let deltaY: CGFloat } + var searchMediaTask: Task? + var buildDataSourceTask: Task? + + var searchMediaInProgress: Bool = false { + didSet { + guard oldValue != searchMediaInProgress else { + return + } + + updateLeftBarButtonItems( + date: navigationItem.leftBarButtonItems?.first === buttonDateBarItem ? buttonDateBarItem : nil, + activity: searchMediaInProgress + ) + } + } + + internal lazy var buttonDateBarItem = UIBarButtonItem( + title: nil, + style: .plain, + target: self, + action: #selector(presentMediaDatePicker) + ) + + internal lazy var searchActivityIndicator: UIActivityIndicatorView = { + let activityIndicator = UIActivityIndicatorView(style: .medium) + activityIndicator.hidesWhenStopped = true + return activityIndicator + }() + + internal lazy var searchActivityBarButtonItem: UIBarButtonItem = { + UIBarButtonItem(customView: searchActivityIndicator) + }() + @MainActor var session: NCSession.Session { NCSession.shared.getSession(controller: tabBarController) @@ -97,6 +126,7 @@ class NCMedia: UIViewController { view.backgroundColor = .systemBackground collectionView.register(UINib(nibName: "NCSectionFirstHeaderEmptyData", bundle: nil), forSupplementaryViewOfKind: mediaSectionHeader, withReuseIdentifier: "sectionFirstHeaderEmptyData") + collectionView.register(UINib(nibName: "NCMediaSectionHeader", bundle: nil), forSupplementaryViewOfKind: mediaSectionHeader, withReuseIdentifier: "sectionHeader") collectionView.register(UINib(nibName: "NCSectionFooter", bundle: nil), forSupplementaryViewOfKind: mediaSectionFooter, withReuseIdentifier: "sectionFooter") collectionView.register(UINib(nibName: "NCMediaCell", bundle: nil), forCellWithReuseIdentifier: "mediaCell") collectionView.alwaysBounceVertical = true @@ -128,17 +158,17 @@ class NCMedia: UIViewController { UIColor.clear.cgColor ] - gradientLayer.locations = [0.0, 0.20, 0.40, 0.60, 0.75, 0.85, 0.95, 1.0] - gradientView.layer.insertSublayer(gradientLayer, at: 0) - - titleDate.text = "" - titleDate?.textColor = .white - activityIndicator.color = .white + navigationItem.leftItemsSupplementBackButton = true + navigationItem.leftBarButtonItem = nil pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(handlePinchGesture(_:))) collectionView.addGestureRecognizer(pinchGesture) - NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: global.notificationCenterChangeUser), object: nil, queue: nil) { notification in + NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: global.notificationCenterChangeUser), object: nil, queue: nil) { [weak self] notification in + guard let self else { + return + } + Task { @MainActor in guard let userInfo = notification.userInfo, let account = userInfo["account"] as? String else { @@ -147,12 +177,16 @@ class NCMedia: UIViewController { self.layoutType = self.database.getLayoutForView(account: account, key: self.global.layoutViewMedia, serverUrl: "").layout self.imageCache.removeAll() - await self.loadDataSource() + await self.searchMediaUI(true) } } - NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: global.notificationCenterClearCache), object: nil, queue: nil) { _ in + NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: global.notificationCenterClearCache), object: nil, queue: nil) { [weak self] _ in + guard let self else { + return + } + Task { await self.dataSource.clearCompactMetadatas() self.imageCache.removeAll() @@ -160,7 +194,11 @@ class NCMedia: UIViewController { } } - NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { _ in + NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { [weak self] _ in + guard let self else { + return + } + Task { await self.networkRemoveAll() } @@ -171,28 +209,45 @@ class NCMedia: UIViewController { super.viewWillAppear(animated) if tabBarSelect == nil { - tabBarSelect = NCMediaSelectTabBar(controller: self.tabBarController, viewController: self, delegate: self) + tabBarSelect = NCMediaSelectTabBar( + controller: self.tabBarController, + viewController: self, + delegate: self + ) } - Task { - await (self.navigationController as? NCMediaNavigationController)?.setNavigationRightItems() + Task { [weak self] in + guard let self else { + return + } + + await (self.navigationController as? NCMediaNavigationController)? + .setNavigationRightItems() + if #unavailable(iOS 26.0) { - (self.navigationController as? NCMediaNavigationController)?.updateRightBarButtonsTint(to: .white) + (self.navigationController as? NCMediaNavigationController)? + .updateRightBarButtonsTint(to: .white) } } - Task { - await loadDataSource() + Task { [weak self] in + guard let self else { + return + } + + await self.networking.transferDispatcher.addDelegate(self) + + guard !Task.isCancelled else { + return + } + + await self.loadDataSource() } } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - Task { - await networking.transferDispatcher.addDelegate(self) - } - NotificationCenter.default.addObserver(self, selector: #selector(enterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil) searchNewMedia() @@ -201,25 +256,43 @@ class NCMedia: UIViewController { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - Task { - await networking.transferDispatcher.removeDelegate(self) - await networkRemoveAll() - } + searchMediaTask?.cancel() + searchMediaTask = nil - NotificationCenter.default.removeObserver(self, name: UIApplication.willEnterForegroundNotification, object: nil) - } + buildDataSourceTask?.cancel() + buildDataSourceTask = nil + + Task { [weak self] in + guard let self else { + return + } + + await self.debouncerSearch.cancel() + await self.debouncerLoadDataSource.cancel() - override func viewWillLayoutSubviews() { - super.viewWillLayoutSubviews() + await self.networking.transferDispatcher.removeDelegate(self) + await self.networkRemoveAll() + } - gradientLayer.frame = gradientView.bounds + NotificationCenter.default.removeObserver( + self, + name: UIApplication.willEnterForegroundNotification, + object: nil + ) } func searchNewMedia() { timerSearchNewMedia?.invalidate() + timerSearchNewMedia = Timer.scheduledTimer(withTimeInterval: timeIntervalSearchNewMedia, repeats: false) { [weak self] _ in - Task { [weak self] in - guard let self else { return } + guard let self else { + return + } + self.searchMediaTask?.cancel() + self.searchMediaTask = Task { [weak self] in + guard let self else { + return + } await self.searchMediaUI() } } @@ -248,7 +321,6 @@ class NCMedia: UIViewController { case 0...1: pointSize = 60 case 2...3: pointSize = 30 case 4...5: pointSize = 25 - case 6...Int(maxColumns): pointSize = 20 default: pointSize = 20 } if let image = UIImage(systemName: "photo.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: pointSize))?.withTintColor(.systemGray4, renderingMode: .alwaysOriginal) { @@ -264,29 +336,22 @@ class NCMedia: UIViewController { extension NCMedia: UIScrollViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { + setTitleDate() + if !dataSource.compactMetadatas.isEmpty { - setTitleDate() setNeedsStatusBarAppearanceUpdate() } - setElements() - } - - func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { if !decelerate { - if !decelerate { - searchNewMedia() - } + searchNewMedia() } } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { searchNewMedia() } - - func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { } } // MARK: - @@ -302,7 +367,9 @@ extension NCMedia: NCSelectDelegate { await database.setAccountMediaPathAsync(mediaPath, account: session.account) imageCache.removeAll() - await loadDataSource() + await self.debouncerLoadDataSource.call { + await self.loadDataSource() + } searchNewMedia() } } diff --git a/iOSClient/Media/NCMediaDataSource.swift b/iOSClient/Media/NCMediaDataSource.swift index 831ade75df..cb2050a794 100644 --- a/iOSClient/Media/NCMediaDataSource.swift +++ b/iOSClient/Media/NCMediaDataSource.swift @@ -10,13 +10,18 @@ extension NCMedia { func loadDataSource() async { let account = self.session.account + guard !Task.isCancelled else { + return + } + guard let tblAccount = await self.database.getTableAccountAsync( predicate: NSPredicate(format: "account == %@", account) ) else { return } - guard self.session.account == account else { + guard !Task.isCancelled, + self.session.account == account else { return } @@ -24,49 +29,73 @@ extension NCMedia { session: self.session, mediaPath: tblAccount.mediaPath, showOnlyImages: self.showOnlyImages, - showOnlyVideos: self.showOnlyVideos) + showOnlyVideos: self.showOnlyVideos + ) - guard self.session.account == account else { + guard !Task.isCancelled, + self.session.account == account else { return } - if let metadatas = await self.database.getMetadatasAsync( + let compactMetadatas = await self.database.getMediaCompactMetadatasAsync( predicate: mediaPredicate, sortedByKeyPath: "date", ascending: false - ) { - guard self.session.account == account else { + ) + + guard !Task.isCancelled, + self.session.account == account else { + return + } + + self.buildDataSourceTask?.cancel() + + self.buildDataSourceTask = Task(priority: .userInitiated) { [weak self] in + guard let self else { return } - self.database.filterAndNormalizeLivePhotos(from: metadatas) { metadatas in - Task { @MainActor in - guard self.isViewActived, - self.session.account == account else { - return - } + let shouldContinue = await MainActor.run { + self.isViewActived && + self.session.account == account && + self.view.window != nil && + self.tabBarController?.selectedViewController === self.navigationController + } - self.dataSource = NCMediaDataSource(metadatas: metadatas) - self.collectionViewReloadDataKeepingPosition() - } + guard shouldContinue, + !Task.isCancelled else { + return + } + + let dataSource = NCMediaDataSource( + compactMetadatas: compactMetadatas + ) + + guard !Task.isCancelled else { + return } - } else { + await MainActor.run { - guard self.isViewActived, - self.session.account == account else { + guard !Task.isCancelled, + self.isViewActived, + self.session.account == account, + self.view.window != nil, + self.tabBarController?.selectedViewController === self.navigationController else { return } - self.dataSource.clearCompactMetadatas() + self.dataSource = dataSource self.collectionViewReloadData() } } + + await self.buildDataSourceTask?.value } @MainActor func collectionViewReloadData() { collectionView.reloadData() - setElements() + setTitleDate() } // MARK: - Keeping position @@ -147,51 +176,41 @@ extension NCMedia { ) } - @MainActor - func collectionViewReloadDataKeepingPosition() { - let anchor = captureScrollAnchor() - - collectionView.reloadData() - collectionView.layoutIfNeeded() - - DispatchQueue.main.async { - guard self.isViewActived else { - return - } - - self.collectionView.layoutIfNeeded() - self.restoreScrollAnchor(anchor) - self.setElements() - } - } - // MARK: - Search media func searchMediaUI(_ distant: Bool = false) async { let shouldContinue = await MainActor.run { () -> Bool in guard self.isViewActived, - !self.searchMediaInProgress, - !self.isPinchGestureActive, - !self.showOnlyImages, - !self.showOnlyVideos, - !self.isEditMode else { + !self.searchMediaInProgress, + !self.isPinchGestureActive, + !self.showOnlyImages, + !self.showOnlyVideos, + !self.isEditMode else { return false } + self.searchMediaInProgress = true - self.activityIndicator.startAnimating() return true } guard shouldContinue else { return } - let account = self.session.account + await searchMediaUIInternal(distant) + + await MainActor.run { + self.searchMediaInProgress = false + } + } + + private func searchMediaUIInternal(_ distant: Bool) async { + guard !Task.isCancelled else { + return + } + + let account = self.session.account guard let tblAccount = await self.database.getTableAccountAsync(predicate: NSPredicate(format: "account == %@", account)) else { - await MainActor.run { - self.activityIndicator.stopAnimating() - self.searchMediaInProgress = false - } return } @@ -257,11 +276,9 @@ extension NCMedia { } } - guard self.session.account == account else { - await MainActor.run { - self.activityIndicator.stopAnimating() - self.searchMediaInProgress = false - } + guard !Task.isCancelled, + self.isViewActived, + self.session.account == account else { return } @@ -272,27 +289,28 @@ extension NCMedia { lastDate: lastDateNew, mediaPath: tblAccount.mediaPath, account: account) { - Task { - guard self.session.account == account else { + Task { [weak self] in + guard let self else { return } - await self.loadDataSource() + + await self.debouncerLoadDataSource.call { + guard self.isViewActived, + self.session.account == account else { + return + } + + await self.loadDataSource() + } } } } - guard self.session.account == account else { - await MainActor.run { - self.activityIndicator.stopAnimating() - self.searchMediaInProgress = false - } - return - } - guard let firstDate, let lastDate else { - Task { @MainActor in - self.activityIndicator.stopAnimating() - self.searchMediaInProgress = false - } + guard !Task.isCancelled, + self.isViewActived, + self.session.account == account, + let firstDate, + let lastDate else { return } @@ -302,24 +320,21 @@ extension NCMedia { lastDate: lastDate, mediaPath: tblAccount.mediaPath, account: account) { - Task { - guard self.session.account == account else { + Task { [weak self] in + guard let self else { return } await self.debouncerLoadDataSource.call { - guard self.session.account == account else { + guard self.isViewActived, + self.session.account == account else { return } + await self.loadDataSource() } } - } finish: { - Task { @MainActor in - self.activityIndicator.stopAnimating() - self.searchMediaInProgress = false - } - } + } finish: { } } /// Searches the server for new media within the given date range, @@ -349,17 +364,23 @@ extension NCMedia { task: task) } } update: { files in - guard self.session.account == account else { + guard !Task.isCancelled, + self.session.account == account else { return } - await self.updateMediaMetadatas(files: files, - firstDate: firstDate as NSDate, - lastDate: lastDate as NSDate, - mediaPath: mediaPath, - account: account) { - guard self.session.account == account else { + + await self.updateMediaMetadatas( + files: files, + firstDate: firstDate as NSDate, + lastDate: lastDate as NSDate, + mediaPath: mediaPath, + account: account + ) { + guard !Task.isCancelled, + self.session.account == account else { return } + if self.isViewActived { update() } @@ -382,7 +403,7 @@ extension NCMedia { account: account, paginate: true, limit: 1000000) { task in - Task.detached { + Task { let identifier = await NCNetworking.shared.networkingTasks.createIdentifier( account: account, name: "verifyNetworkMedia" @@ -392,20 +413,26 @@ extension NCMedia { task: task) } } update: { files in - guard self.session.account == account else { + guard !Task.isCancelled, + self.session.account == account else { return } + let pageFirstDate = (files.first?.date as? NSDate) ?? firstDate as NSDate let pageLastDate = (files.last?.date as? NSDate) ?? lastDate as NSDate - await self.updateMediaMetadatas(files: files, - firstDate: pageFirstDate, - lastDate: pageLastDate, - mediaPath: mediaPath, - account: account) { - guard self.session.account == account else { + await self.updateMediaMetadatas( + files: files, + firstDate: pageFirstDate, + lastDate: pageLastDate, + mediaPath: mediaPath, + account: account + ) { + guard !Task.isCancelled, + self.session.account == account else { return } + if self.isViewActived { update() } @@ -421,7 +448,8 @@ extension NCMedia { mediaPath: String, account: String, update: @escaping () -> Void) async { - guard self.session.account == account else { + guard !Task.isCancelled, + self.session.account == account else { return } // DB @@ -437,12 +465,14 @@ extension NCMedia { predicate: predicate, sortedByKeyPath: "date", ascending: false) ?? [] - guard self.session.account == account else { + guard !Task.isCancelled, + self.session.account == account else { return } let results = await self.database.syncPlaceholderMetadatasAsync(files: files, metadatas: metadatas) - guard self.session.account == account else { + guard !Task.isCancelled, + self.session.account == account else { return } // DELETE @@ -459,22 +489,41 @@ extension NCMedia { } for batchStart in stride(from: 0, to: deletedMetadatas.count, by: maximumConcurrentChecks) { + guard !Task.isCancelled, + self.session.account == account else { + return + } + let batchEnd = min(batchStart + maximumConcurrentChecks, deletedMetadatas.count) let batch = deletedMetadatas[batchStart...self) { group in for metadata in batch { group.addTask { + guard !Task.isCancelled else { + return nil + } + let existsResult = await self.networking.fileExists( serverUrlFileName: metadata.serverUrlFileName, account: metadata.account ) + + guard !Task.isCancelled else { + return nil + } + return existsResult.errorCode == 404 ? metadata.ocId : nil } } var ocIds = Set() for await ocId in group { + guard !Task.isCancelled else { + group.cancelAll() + return ocIds + } + if let ocId { ocIds.insert(ocId) } @@ -482,13 +531,15 @@ extension NCMedia { return ocIds } + guard !Task.isCancelled, + self.session.account == account else { + return + } + guard !ocIdsToDelete.isEmpty else { continue } - guard self.session.account == account else { - return - } await self.database.deleteMetadatasAsync(ocIds: Array(ocIdsToDelete)) if self.isViewActived, self.session.account == account { @@ -500,7 +551,6 @@ extension NCMedia { // MARK: - -@MainActor public class NCMediaDataSource: NSObject { public class NCCompactMetadata: NSObject { let date: Date @@ -511,13 +561,13 @@ public class NCMediaDataSource: NSObject { let isVideo: Bool let ocId: String - init(date: Date, - etag: String, - imageSize: CGSize, - isImage: Bool, - isLivePhoto: Bool, - isVideo: Bool, - ocId: String) { + public init(date: Date, + etag: String, + imageSize: CGSize, + isImage: Bool, + isLivePhoto: Bool, + isVideo: Bool, + ocId: String) { self.date = date self.etag = etag self.imageSize = imageSize @@ -528,71 +578,220 @@ public class NCMediaDataSource: NSObject { } } + public struct NCMediaSection { + let yearMonth: NCYearMonth + var compactMetadatas: [NCCompactMetadata] + } + private let utilityFileSystem = NCUtilityFileSystem() private let global = NCGlobal.shared private(set) var compactMetadatas: [NCCompactMetadata] = [] + private(set) var sections: [NCMediaSection] = [] + + var availableYearMonths: [NCYearMonth] { + sections.map(\.yearMonth) + } override init() { super.init() } - init(metadatas: [tableMetadata]) { + init(compactMetadatas: [NCCompactMetadata]) { super.init() - self.compactMetadatas = metadatas.map { - getCompactMetadataFromMetadata($0) - } + let result = makeDataSource(from: compactMetadatas) + + self.compactMetadatas = result.compactMetadatas + self.sections = result.sections } - private func getCompactMetadataFromMetadata(_ metadata: tableMetadata) -> NCCompactMetadata { - let date = metadata.date as Date - return NCCompactMetadata(date: date, - etag: metadata.etag, - imageSize: CGSize(width: metadata.width, height: metadata.height), - isImage: metadata.classFile == NKTypeClassFile.image.rawValue, - isLivePhoto: !metadata.livePhotoFile.isEmpty, - isVideo: metadata.classFile == NKTypeClassFile.video.rawValue, - ocId: metadata.ocId) + private func makeDataSource( + from compactMetadatas: [NCCompactMetadata] + ) -> ( + compactMetadatas: [NCCompactMetadata], + sections: [NCMediaSection] + ) { + guard !compactMetadatas.isEmpty else { + return ([], []) + } + + var sections: [NCMediaSection] = [] + sections.reserveCapacity(24) + + var currentYearMonth: NCYearMonth? + var currentSectionMetadatas: [NCCompactMetadata] = [] + + for compactMetadata in compactMetadatas { + guard let yearMonth = NCYearMonth(date: compactMetadata.date) else { + continue + } + + if currentYearMonth == yearMonth { + currentSectionMetadatas.append(compactMetadata) + } else { + if let currentYearMonth, + !currentSectionMetadatas.isEmpty { + sections.append( + NCMediaSection( + yearMonth: currentYearMonth, + compactMetadatas: currentSectionMetadatas + ) + ) + } + + currentYearMonth = yearMonth + currentSectionMetadatas = [compactMetadata] + } + } + + if let currentYearMonth, + !currentSectionMetadatas.isEmpty { + sections.append( + NCMediaSection( + yearMonth: currentYearMonth, + compactMetadatas: currentSectionMetadatas + ) + ) + } + + return ( + compactMetadatas, + sections + ) } // MARK: - func clearCompactMetadatas() { - self.compactMetadatas.removeAll() + compactMetadatas.removeAll() + sections.removeAll() } func isEmpty() -> Bool { return self.compactMetadatas.isEmpty } - func indexPath(forOcId ocId: String) -> IndexPath? { - guard let index = self.compactMetadatas.firstIndex(where: { $0.ocId == ocId }) else { + var numberOfSections: Int { + sections.count + } + + func numberOfItems(in section: Int) -> Int { + guard sections.indices.contains(section) else { + return 0 + } + + return sections[section].compactMetadatas.count + } + + func yearMonth(for section: Int) -> NCYearMonth? { + guard sections.indices.contains(section) else { return nil } - return IndexPath(item: index, section: 0) + return sections[section].yearMonth } - func getCompactMetadata(indexPath: IndexPath) -> NCCompactMetadata? { - if indexPath.row < self.compactMetadatas.count { - return self.compactMetadatas[indexPath.row] + var allOcIds: [String] { + compactMetadatas.map(\.ocId) + } + + func indexPath(forOcId ocId: String) -> IndexPath? { + for (sectionIndex, section) in sections.enumerated() { + guard let itemIndex = section.compactMetadatas.firstIndex(where: { + $0.ocId == ocId + }) else { + continue + } + + return IndexPath( + item: itemIndex, + section: sectionIndex + ) } return nil } + func getCompactMetadata(indexPath: IndexPath) -> NCCompactMetadata? { + guard sections.indices.contains(indexPath.section) else { + return nil + } + + let metadatas = sections[indexPath.section].compactMetadatas + + guard metadatas.indices.contains(indexPath.item) else { + return nil + } + + return metadatas[indexPath.item] + } + func getCompactMetadatas(indexPaths: [IndexPath]) -> [NCCompactMetadata] { - var metadatas: [NCCompactMetadata] = [] - for indexPath in indexPaths { - if indexPath.row < self.compactMetadatas.count { - metadatas.append(self.compactMetadatas[indexPath.row]) + indexPaths.compactMap { + getCompactMetadata(indexPath: $0) + } + } + + func removeCompactMetadata(_ ocIds: [String]) { + guard !ocIds.isEmpty else { + return + } + + let ocIds = Set(ocIds) + + compactMetadatas.removeAll { metadata in + ocIds.contains(metadata.ocId) + } + + sections = sections.compactMap { section in + var section = section + + section.compactMetadatas.removeAll { metadata in + ocIds.contains(metadata.ocId) } + + return section.compactMetadatas.isEmpty ? nil : section + } + } + + func firstIndexPath(year: Int, month: Int) -> IndexPath? { + let yearMonth = NCYearMonth( + year: year, + month: month + ) + + guard let sectionIndex = sections.firstIndex(where: { + $0.yearMonth == yearMonth + }) else { + return nil + } + + guard !sections[sectionIndex].compactMetadatas.isEmpty else { + return nil } - return metadatas + return IndexPath( + item: 0, + section: sectionIndex + ) + } +} + +public struct NCYearMonth: Hashable { + public let year: Int + public let month: Int + + public init(year: Int, month: Int) { + self.year = year + self.month = month } - func removeCompactMetadata(_ ocId: [String]) { - self.compactMetadatas.removeAll { item in - ocId.contains(item.ocId) + init?(date: Date, calendar: Calendar = .current) { + let components = calendar.dateComponents([.year, .month], from: date) + + guard let year = components.year, + let month = components.month else { + return nil } + + self.init(year: year, month: month) } } diff --git a/iOSClient/Media/NCMediaDatePickerViewController.swift b/iOSClient/Media/NCMediaDatePickerViewController.swift new file mode 100644 index 0000000000..06f88daf13 --- /dev/null +++ b/iOSClient/Media/NCMediaDatePickerViewController.swift @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import UIKit + +@MainActor +final class NCMediaDatePickerViewController: UIHostingController { + private let model: NCMediaDatePickerModel + + var onDateSelected: ((NCYearMonth) -> Void)? { + get { + model.onDateSelected + } + set { + model.onDateSelected = newValue + } + } + + init( + availableYearMonths: [NCYearMonth], + selectedYearMonth: NCYearMonth? + ) { + let model = NCMediaDatePickerModel( + availableYearMonths: availableYearMonths, + selectedYearMonth: selectedYearMonth + ) + + self.model = model + + super.init( + rootView: NCMediaDatePickerView(model: model) + ) + + view.backgroundColor = .clear + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +// MARK: - Model + +@MainActor +final class NCMediaDatePickerModel: ObservableObject { + let availableYearMonths: [NCYearMonth] + + @Published var selectedYearMonth: NCYearMonth? + + var onDateSelected: ((NCYearMonth) -> Void)? + + init( + availableYearMonths: [NCYearMonth], + selectedYearMonth: NCYearMonth? + ) { + self.availableYearMonths = availableYearMonths + + if let selectedYearMonth, + availableYearMonths.contains(selectedYearMonth) { + self.selectedYearMonth = selectedYearMonth + } else { + self.selectedYearMonth = availableYearMonths.first + } + } + + func title(for yearMonth: NCYearMonth) -> String { + var components = DateComponents() + components.year = yearMonth.year + components.month = yearMonth.month + components.day = 1 + + guard let date = Calendar.current.date(from: components) else { + return "\(yearMonth.month) \(yearMonth.year)" + } + + return date.formatted( + .dateTime + .month(.wide) + .year() + ) + } +} + +// MARK: - View + +struct NCMediaDatePickerView: View { + @ObservedObject var model: NCMediaDatePickerModel + + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + ZStack { + Text(NSLocalizedString("_select_date_", comment: "")) + .font(.system(size: 17, weight: .semibold)) + .lineLimit(1) + + HStack { + Spacer() + + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 17, weight: .semibold)) + .frame(width: 24, height: 24) + } + .modifier(NCMediaCloseButtonStyle()) + .frame(width: 44, height: 44) + .accessibilityLabel( + Text(NSLocalizedString("_close_", comment: "")) + ) + } + } + .frame(height: 52) + .padding(.horizontal, 16) + + Picker("", selection: $model.selectedYearMonth) { + ForEach(model.availableYearMonths, id: \.self) { yearMonth in + Text(model.title(for: yearMonth)) + .tag(Optional(yearMonth)) + } + } + .pickerStyle(.wheel) + .labelsHidden() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .layoutPriority(1) + .clipped() + .onChange(of: model.selectedYearMonth) { _, selectedYearMonth in + guard let selectedYearMonth else { + return + } + + model.onDateSelected?(selectedYearMonth) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +private struct NCMediaCloseButtonStyle: ViewModifier { + @ViewBuilder + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content + .buttonStyle(.glass) + .buttonBorderShape(.circle) + } else { + content + .buttonStyle(.plain) + } + } +} + +#if DEBUG +#Preview("Media Date Picker") { + NCMediaDatePickerView( + model: NCMediaDatePickerModel( + availableYearMonths: [ + NCYearMonth(year: 2026, month: 7), + NCYearMonth(year: 2026, month: 6), + NCYearMonth(year: 2026, month: 5), + NCYearMonth(year: 2026, month: 4), + NCYearMonth(year: 2026, month: 3), + NCYearMonth(year: 2026, month: 2), + NCYearMonth(year: 2026, month: 1), + NCYearMonth(year: 2025, month: 12), + NCYearMonth(year: 2025, month: 11) + ], + selectedYearMonth: NCYearMonth(year: 2026, month: 7) + ) + ) + .frame(height: 340) +} +#endif diff --git a/iOSClient/Media/NCMediaLayout.swift b/iOSClient/Media/NCMediaLayout.swift index 4cfce7d413..2ba15ecb28 100644 --- a/iOSClient/Media/NCMediaLayout.swift +++ b/iOSClient/Media/NCMediaLayout.swift @@ -29,11 +29,7 @@ public class NCMediaLayout: UICollectionViewLayout { private let unionSize = 20 // MARK: - Public Properties - public var columnCount: Int = 0 { - didSet { - invalidateIfNotEqual(oldValue, newValue: columnCount) - } - } + public private(set) var columnCount: Int = 1 public var minimumColumnSpacing: Float = 1.0 { didSet { invalidateIfNotEqual(oldValue, newValue: minimumColumnSpacing) @@ -70,14 +66,14 @@ public class NCMediaLayout: UICollectionViewLayout { } } public override var collectionViewContentSize: CGSize { - let numberOfSections = collectionView?.numberOfSections - if numberOfSections == 0 { - return CGSize.zero + guard let collectionView else { + return .zero } - var contentSize = collectionView?.bounds.size - contentSize?.height = CGFloat(columnHeights[0]) - return contentSize! + return CGSize( + width: collectionView.bounds.width, + height: CGFloat(columnHeights.max() ?? 0) + ) } public var frameWidth: Float = 0 public var itemWidth: Float = 0 @@ -101,8 +97,11 @@ public class NCMediaLayout: UICollectionViewLayout { let collectionView = collectionView, let delegate = delegate else { return } - columnCount = delegate.getColumnCount() - (delegate as? NCMedia)?.buildMediaPhotoVideo(columnCount: columnCount) + let resolvedColumnCount = max(1, delegate.getColumnCount()) + + if columnCount != resolvedColumnCount { + columnCount = resolvedColumnCount + } // Initialize variables headersAttribute.removeAll(keepingCapacity: false) @@ -126,7 +125,7 @@ public class NCMediaLayout: UICollectionViewLayout { */ let minimumInteritemSpacing: Float = delegate.collectionView(collectionView, layout: self, minimumInteritemSpacingForSection: section) let sectionInset: UIEdgeInsets = delegate.collectionView(collectionView, layout: self, insetForSection: section) - frameWidth = Float(collectionView.frame.size.width - sectionInset.left - sectionInset.right) + frameWidth = Float(collectionView.bounds.width - sectionInset.left - sectionInset.right) itemWidth = ((frameWidth - Float(columnCount - 1) * Float(minimumColumnSpacing)) / Float(columnCount)) /* @@ -139,7 +138,7 @@ public class NCMediaLayout: UICollectionViewLayout { if headerHeight > 0 { attributes = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: mediaSectionHeader, with: NSIndexPath(item: 0, section: section) as IndexPath) - attributes.frame = CGRect(x: headerInset.left, y: CGFloat(top), width: collectionView.frame.size.width - (headerInset.left + headerInset.right), height: CGFloat(headerHeight)) + attributes.frame = CGRect(x: headerInset.left, y: CGFloat(top), width: collectionView.bounds.width - (headerInset.left + headerInset.right), height: CGFloat(headerHeight)) headersAttribute[section] = attributes allItemAttributes.append(attributes) @@ -167,7 +166,9 @@ public class NCMediaLayout: UICollectionViewLayout { if UIView.userInterfaceLayoutDirection(for: collectionView.semanticContentAttribute) == .leftToRight { xOffset = Float(sectionInset.left) + Float(itemWidth + minimumColumnSpacing) * Float(columnIndex) } else { - xOffset = Float(collectionView.frame.width) - Float(sectionInset.right) - Float(itemWidth + minimumColumnSpacing) * Float(columnIndex + 1) + xOffset = Float(collectionView.bounds.width) + - Float(sectionInset.right) + - Float(itemWidth + minimumColumnSpacing) * Float(columnIndex + 1) } let yOffset = columnHeights[columnIndex] @@ -191,13 +192,30 @@ public class NCMediaLayout: UICollectionViewLayout { * 4. Section footer */ let columnIndex = longestColumnIndex() - top = columnHeights[columnIndex] - minimumInteritemSpacing + Float(sectionInset.bottom) + top = columnHeights[columnIndex] + + if itemCount > 0 { + top -= minimumInteritemSpacing + } + + top += Float(sectionInset.bottom) + let footerInset = delegate.collectionView( + collectionView, + layout: self, + insetForFooterInSection: section + ) + top += Float(footerInset.top) - let footerHeight = delegate.collectionView(collectionView, layout: self, heightForFooterInSection: section) + + let footerHeight = delegate.collectionView( + collectionView, + layout: self, + heightForFooterInSection: section + ) if footerHeight > 0 { attributes = UICollectionViewLayoutAttributes(forSupplementaryViewOfKind: mediaSectionFooter, with: NSIndexPath(item: 0, section: section) as IndexPath) - attributes.frame = CGRect(x: footerInset.left, y: CGFloat(top), width: collectionView.frame.size.width - (footerInset.left + footerInset.right), height: CGFloat(footerHeight)) + attributes.frame = CGRect(x: footerInset.left, y: CGFloat(top), width: collectionView.bounds.width - (footerInset.left + footerInset.right), height: CGFloat(footerHeight)) footersAttribute[section] = attributes allItemAttributes.append(attributes) @@ -211,15 +229,24 @@ public class NCMediaLayout: UICollectionViewLayout { } // Build union rects - var idx = 0 - let itemCounts = allItemAttributes.count - - while idx < itemCounts { - let rect1 = allItemAttributes[idx].frame - idx = min(idx + unionSize, itemCounts) - 1 - let rect2 = allItemAttributes[idx].frame - unionRects.append(rect1.union(rect2)) - idx += 1 + unionRects.removeAll(keepingCapacity: true) + + var startIndex = 0 + + while startIndex < allItemAttributes.count { + let endIndex = min( + startIndex + unionSize, + allItemAttributes.count + ) + + var unionRect = allItemAttributes[startIndex].frame + + for index in (startIndex + 1).. 1 && numberOfColumns < maxColumns { - collectionView.transform = CGAffineTransform(scaleX: currentScale, y: currentScale) + collectionView.transform = CGAffineTransform( + scaleX: currentScale, + y: currentScale + ) } lastScale = scale + case .ended: UIView.animate(withDuration: 0.30) { self.currentScale = 1.0 self.collectionView.transform = .identity self.setTitleDate() } + default: break } diff --git a/iOSClient/Media/Section header/NCMediaSectionHeader.swift b/iOSClient/Media/Section header/NCMediaSectionHeader.swift new file mode 100644 index 0000000000..c6cf1cc90b --- /dev/null +++ b/iOSClient/Media/Section header/NCMediaSectionHeader.swift @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import UIKit + +class NCMediaSectionHeader: UICollectionReusableView { + @IBOutlet weak var titleLabel: UILabel! + + override func awakeFromNib() { + super.awakeFromNib() + self.backgroundColor = UIColor.clear + self.titleLabel.text = "" + } +} diff --git a/iOSClient/Media/Section header/NCMediaSectionHeader.xib b/iOSClient/Media/Section header/NCMediaSectionHeader.xib new file mode 100644 index 0000000000..b2f7f82a71 --- /dev/null +++ b/iOSClient/Media/Section header/NCMediaSectionHeader.xib @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOSClient/NCImageCache.swift b/iOSClient/NCImageCache.swift index fff8796183..286d01296d 100644 --- a/iOSClient/NCImageCache.swift +++ b/iOSClient/NCImageCache.swift @@ -55,40 +55,36 @@ final class NCImageCache: @unchecked Sendable { return } - let predicate = self.getMediaPredicate(session: session, - mediaPath: tblAccount.mediaPath, - showOnlyImages: false, - showOnlyVideos: false) - guard let metadatas = await self.database.getMetadatasAsync(predicate: predicate, - sortedByKeyPath: "date", - limit: self.countLimit) else { - self.isLoadingCache = false - return - } - - self.database.filterAndNormalizeLivePhotos(from: metadatas) { metadatas in - autoreleasepool { - self.cache.removeAllObjects() - for metadata in metadatas { - guard !isAppInBackground else { - self.cache.removeAllObjects() - break - } - - if let image = self.utility.getImage(ocId: metadata.ocId, - etag: metadata.etag, - ext: self.global.previewExt256, - userId: metadata.userId, - urlBase: metadata.urlBase) { - self.addImageCache(ocId: metadata.ocId, - etag: metadata.etag, - image: image, - ext: self.global.previewExt256) - } + let mediaPredicate = self.getMediaPredicate(session: session, + mediaPath: tblAccount.mediaPath, + showOnlyImages: false, + showOnlyVideos: false) + + let compactMetadatas = await self.database.getMediaCompactMetadatasAsync( + predicate: mediaPredicate, + sortedByKeyPath: "date", + ascending: false + ) + + autoreleasepool { + self.cache.removeAllObjects() + for compactMetadata in compactMetadatas { + guard !isAppInBackground else { + self.cache.removeAllObjects() + break + } + if let image = self.utility.getImage(ocId: compactMetadata.ocId, + etag: compactMetadata.etag, + ext: self.global.previewExt256, + userId: session.userId, + urlBase: session.urlBase) { + self.addImageCache(ocId: compactMetadata.ocId, + etag: compactMetadata.etag, + image: image, + ext: self.global.previewExt256) } - - self.isLoadingCache = false } + self.isLoadingCache = false } } } diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 31aeff1f27..d183d94a19 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -707,6 +707,7 @@ "_media_not_available_" = "Media not available"; "_no_assistant_installed_" = "Assistant is not installed on this server. Ask your administrator to install the Assistant app"; "_open_in_office_" = "Open in Office"; +"_select_date_" = "Select date"; // Tip "_tip_pdf_thumbnails_" = "Swipe left from the right edge of the screen to show the thumbnails"; diff --git a/iOSClient/Utility/NCDebouncer.swift b/iOSClient/Utility/NCDebouncer.swift index a6307f28b7..92f6dddfc7 100644 --- a/iOSClient/Utility/NCDebouncer.swift +++ b/iOSClient/Utility/NCDebouncer.swift @@ -9,6 +9,7 @@ public actor NCDebouncer { private let maxEventCount: Int private var eventCount: Int = 0 private var pendingTask: Task? + private var executionTask: Task? private var latestBlock: (@MainActor @Sendable () async -> Void)? private var isPaused: Bool = false @@ -25,8 +26,6 @@ public actor NCDebouncer { latestBlock = block guard !isPaused else { - // We only store the latest block and count events, - // but we never schedule or commit while paused. eventCount += 1 return } @@ -50,14 +49,11 @@ public actor NCDebouncer { } isPaused = true + pendingTask?.cancel() pendingTask = nil } - public func isPausedNow() -> Bool { - return isPaused - } - public func resume() { guard isPaused else { return @@ -65,7 +61,6 @@ public actor NCDebouncer { isPaused = false - // If something accumulated while paused, commit immediately. if latestBlock != nil { commit() } @@ -74,20 +69,41 @@ public actor NCDebouncer { public func cancel() { pendingTask?.cancel() pendingTask = nil + + executionTask?.cancel() + executionTask = nil + latestBlock = nil eventCount = 0 } + public func isPausedNow() -> Bool { + isPaused + } + // MARK: - Internal private func scheduleIfNeeded() { - guard pendingTask == nil, !isPaused else { + guard pendingTask == nil, + !isPaused else { return } pendingTask = Task { [weak self] in - guard let self else { return } - try? await Task.sleep(for: self.delay) + guard let self else { + return + } + + do { + try await Task.sleep(for: self.delay) + } catch { + return + } + + guard !Task.isCancelled else { + return + } + await self.commit() } } @@ -101,13 +117,32 @@ public actor NCDebouncer { pendingTask = nil eventCount = 0 - guard let block = latestBlock else { + guard executionTask == nil, + let block = latestBlock else { return } + latestBlock = nil - Task { @MainActor in + executionTask = Task { @MainActor [weak self] in + guard !Task.isCancelled else { + await self?.executionDidFinish() + return + } + await block() + await self?.executionDidFinish() + } + } + + private func executionDidFinish() { + executionTask = nil + + guard !isPaused, + latestBlock != nil else { + return } + + commit() } }