Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
6114cc0
feat: prepare media date navigation by month
marinofaggiana Jul 22, 2026
eccd091
fix: improve media date button styling and gradient transition
marinofaggiana Jul 22, 2026
09d7294
feat: add date picker navigation to media timeline
marinofaggiana Jul 22, 2026
a343b06
fix: show visible date range in media header
marinofaggiana Jul 22, 2026
f87a4c5
refactor: migrate media date picker to SwiftUI
marinofaggiana Jul 22, 2026
da3d2f6
feat: add title to media date picker
marinofaggiana Jul 22, 2026
5ea946b
refactor: move media date range to navigation bar
marinofaggiana Jul 22, 2026
de264aa
fix: correct media collection layout calculations
marinofaggiana Jul 22, 2026
7af6c9b
fix: improve media grid updates and viewer selection
marinofaggiana Jul 22, 2026
581b4b3
feat: group media into monthly sections
marinofaggiana Jul 22, 2026
4189b40
feat: add dedicated media section headers
marinofaggiana Jul 22, 2026
64deec3
fix: align media navigation with section headers
marinofaggiana Jul 22, 2026
7340675
fix: debounce and cancel stale media reloads
marinofaggiana Jul 22, 2026
df2624f
perf: build media data source off the main actor
marinofaggiana Jul 23, 2026
532eddd
perf: optimize media data source updates
marinofaggiana Jul 23, 2026
834b0fc
TEST
marinofaggiana Jul 23, 2026
7ae7918
fix: show media search activity in navigation bar
marinofaggiana Jul 23, 2026
331d5ba
fix: cancel stale media loading tasks
marinofaggiana Jul 23, 2026
d8d2c65
fix: cancel media tasks when view disappears
marinofaggiana Jul 23, 2026
fbc0ab8
refactor: build media data source from compact metadata
marinofaggiana Jul 23, 2026
373705c
fix: reduce maximum grid columns
marinofaggiana Jul 23, 2026
25de1b3
Merge branch 'master' into jump-to-date
marinofaggiana Jul 23, 2026
1aba594
fix: reload image cache when entering foreground
marinofaggiana Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Nextcloud.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */; };
Expand Down Expand Up @@ -1626,6 +1629,8 @@
F77444F422281649000D5EB0 /* NCMediaCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = NCMediaCell.xib; sourceTree = "<group>"; };
F77444F7222816D5000D5EB0 /* NCPickerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCPickerViewController.swift; sourceTree = "<group>"; };
F778231D2C42C07C001BB94F /* NCCollectionViewCommon+MediaLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCCollectionViewCommon+MediaLayout.swift"; sourceTree = "<group>"; };
F77A6B2D301112C5007103E6 /* NCMediaSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaSectionHeader.swift; sourceTree = "<group>"; };
F77A6B2E301112C5007103E6 /* NCMediaSectionHeader.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = NCMediaSectionHeader.xib; sourceTree = "<group>"; };
F77B0F883008ABDC00EE4AE1 /* NCBlurEffectsTestView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCBlurEffectsTestView.swift; sourceTree = "<group>"; };
F77BB745289984CA0090FC19 /* UIViewController+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIViewController+Extension.swift"; sourceTree = "<group>"; };
F77BB747289985270090FC19 /* UITabBarController+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UITabBarController+Extension.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1762,6 +1767,7 @@
F7BB04851FD58ACB00BBFD2A /* cs-CZ */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "cs-CZ"; path = "cs-CZ.lproj/Localizable.strings"; sourceTree = "<group>"; };
F7BC287D26663F6C004D46C5 /* NCViewCertificateDetails.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = NCViewCertificateDetails.storyboard; sourceTree = "<group>"; };
F7BC287F26663F85004D46C5 /* NCViewCertificateDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCViewCertificateDetails.swift; sourceTree = "<group>"; };
F7BCE48D301091E400876F4D /* NCMediaDatePickerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaDatePickerViewController.swift; sourceTree = "<group>"; };
F7BD09FF2C468925003A4A6D /* NCMedia+CollectionViewDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCMedia+CollectionViewDataSource.swift"; sourceTree = "<group>"; };
F7BD0A012C4689A4003A4A6D /* NCMedia+CollectionViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCMedia+CollectionViewDelegate.swift"; sourceTree = "<group>"; };
F7BD0A032C4689E9003A4A6D /* NCMedia+MediaLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCMedia+MediaLayout.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -2904,6 +2910,15 @@
path = Files;
sourceTree = "<group>";
};
F77A6B2F301112C5007103E6 /* Section header */ = {
isa = PBXGroup;
children = (
F77A6B2D301112C5007103E6 /* NCMediaSectionHeader.swift */,
F77A6B2E301112C5007103E6 /* NCMediaSectionHeader.xib */,
);
path = "Section header";
sourceTree = "<group>";
};
F77B0F8A3008ACAD00EE4AE1 /* Test */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -3444,6 +3459,7 @@
isa = PBXGroup;
children = (
F720B5B72507B9A5008C94E5 /* Cell */,
F77A6B2F301112C5007103E6 /* Section header */,
F7501C302212E57400FB1415 /* NCMedia.storyboard */,
F7501C312212E57400FB1415 /* NCMedia.swift */,
F7BD09FF2C468925003A4A6D /* NCMedia+CollectionViewDataSource.swift */,
Expand All @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
57 changes: 57 additions & 0 deletions iOSClient/Data/NCManageDatabase+Metadata.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
132 changes: 110 additions & 22 deletions iOSClient/Media/NCMedia+CollectionViewDataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading