diff --git a/Django Files/API/DFAPI.swift b/Django Files/API/DFAPI.swift index 56ca3fd..7144857 100644 --- a/Django Files/API/DFAPI.swift +++ b/Django Files/API/DFAPI.swift @@ -54,6 +54,10 @@ struct DFAPI { var decoder: JSONDecoder private let apiSession: URLSession + // Shared across all DFAPI instances: views construct a DFAPI per request, and a + // per-instance session gets a fresh connection pool — every API call was paying + // a full TCP + TLS handshake instead of reusing keep-alive connections. + private static let defaultSession = URLSession(configuration: .ephemeral) init(url: URL, token: String, session: URLSession? = nil){ self.url = url @@ -61,7 +65,7 @@ struct DFAPI { decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 decoder.keyDecodingStrategy = .convertFromSnakeCase - apiSession = session ?? DFAPIConfiguration.sessionOverride ?? URLSession(configuration: .ephemeral) + apiSession = session ?? DFAPIConfiguration.sessionOverride ?? DFAPI.defaultSession } private func encodeParametersIntoURL(path: String, parameters: [String: String]) -> URL { diff --git a/Django Files/API/Files.swift b/Django Files/API/Files.swift index 21d689d..33c1c13 100644 --- a/Django Files/API/Files.swift +++ b/Django Files/API/Files.swift @@ -271,10 +271,20 @@ extension DFFile { guard let info = exif?["GPSInfo"]?.value as? [String: Any] else { return nil } return info["6"] as? Double } + + /// Server-generated thumbnail endpoint for this file. + public func thumbnailURL(on serverURL: URL) -> URL { + var components = URLComponents( + url: serverURL.appendingPathComponent("/raw/\(name)"), + resolvingAgainstBaseURL: true + ) + components?.queryItems = [URLQueryItem(name: "thumb", value: "true")] + return components?.url ?? serverURL + } } extension DFAPI { - public func getFiles(page: Int = 1, album: Int? = nil, selectedServer: DjangoFilesSession? = nil, filterUserID: Int? = nil, filterMime: String? = nil, filterType: String? = nil, ordering: String? = nil, search: String? = nil) async throws -> DFFilesResponse { + public func getFiles(page: Int = 1, pageSize: Int? = nil, album: Int? = nil, selectedServer: DjangoFilesSession? = nil, filterUserID: Int? = nil, filterMime: String? = nil, filterType: String? = nil, ordering: String? = nil, search: String? = nil) async throws -> DFFilesResponse { var parameters: [String: String] = [:] if let album { parameters["album"] = String(album) @@ -295,8 +305,9 @@ extension DFAPI { parameters["search"] = search } + // /api/files/{page}/{count}/ — count falls back to the server default (25) when omitted let responseBody = try await makeAPIRequest( - path: getAPIPath(.files) + "\(page)/", + path: getAPIPath(.files) + "\(page)/" + (pageSize.map { "\($0)/" } ?? ""), parameters: parameters, method: .get, selectedServer: selectedServer diff --git a/Django Files/Utils/ImageCache.swift b/Django Files/Utils/ImageCache.swift index 503329d..30ad1b7 100644 --- a/Django Files/Utils/ImageCache.swift +++ b/Django Files/Utils/ImageCache.swift @@ -26,7 +26,9 @@ class ImageCache { }() private init() { - cache.countLimit = 500 + // Dense grid zoom (25 columns ≈ 1400 visible cells) needs more entries than 500; + // downsampled thumbs are tiny, so totalCostLimit stays the real memory ceiling. + cache.countLimit = 4000 // Scale-factor-aware cost limit: retina devices use 4× the pixel bytes of logical size. cache.totalCostLimit = 100 * 1024 * 1024 // 100 MB contentCache.countLimit = 100 @@ -69,58 +71,90 @@ class ImageCache { /// Drop-in replacement for AsyncImage with in-memory NSCache + disk URLCache. /// -/// Uses `.task(id: url)` for correct structured-concurrency lifecycle: -/// - Automatically cancelled when the view disappears or the url changes. -/// - Re-started when the view reappears or the url changes. +/// Uses `.task(id:)` for correct structured-concurrency lifecycle: +/// - Automatically cancelled when the view disappears or the url/size changes. +/// - Re-started when the view reappears or the url/size changes. /// - Cache hits are applied synchronously (no placeholder flash). +/// +/// Pass `targetSize` (the view's max dimension in points) to decode a downsampled +/// thumbnail instead of the full image — dense grids composite hundreds of cells, +/// and full-resolution decodes are the main scroll-hitch source. struct CachedAsyncImage: View { let url: URL? + let targetSize: CGFloat? @ViewBuilder let content: (Image) -> Content @ViewBuilder let placeholder: () -> Placeholder + @Environment(\.displayScale) private var displayScale @State private var cachedImage: UIImage? init( url: URL?, + targetSize: CGFloat? = nil, @ViewBuilder content: @escaping (Image) -> Content, @ViewBuilder placeholder: @escaping () -> Placeholder ) { self.url = url + self.targetSize = targetSize self.content = content self.placeholder = placeholder } + private static var buckets: [Int] { [64, 128, 256, 512, 1024] } + + // Bucketed max pixel dimension so nearby zoom levels share one decoded image. + private var pixelBucket: Int? { + guard let targetSize, targetSize > 0 else { return nil } + let pixels = targetSize * displayScale + return Self.buckets.first { CGFloat($0) >= pixels } ?? 1024 + } + + private var cacheKey: String? { + guard let url else { return nil } + guard let pixelBucket else { return url.absoluteString } + return "\(url.absoluteString)#\(pixelBucket)" + } + var body: some View { + // Key built once per evaluation, and the cache read is synchronous: appearing + // cells render their image on the very first frame instead of flashing the + // placeholder until `.task` fires a tick later — and a cache hit never dirties + // @State, so scrolling through already-decoded content causes zero invalidations. + let key = cacheKey + let hit = key.flatMap { ImageCache.shared.get(for: $0) } ?? cachedImage Group { - if let cachedImage { - content(Image(uiImage: cachedImage)) + if let hit { + content(Image(uiImage: hit)) } else { placeholder() } } - .task(id: url) { - await load(url) + .task(id: key) { + await load(url, key: key, maxPixels: pixelBucket) } } - private func load(_ url: URL?) async { - guard let url else { + private func load(_ url: URL?, key: String?, maxPixels: Int?) async { + guard let url, let key else { cachedImage = nil return } - let key = url.absoluteString - if let hit = ImageCache.shared.get(for: key) { - cachedImage = hit - return + // Already rendered synchronously via displayImage — skip the @State write. + if ImageCache.shared.get(for: key) != nil { return } + // Stale-while-revalidate: when zoom changes the bucket, keep showing any + // already-decoded size of this image (GPU rescales it) instead of flashing + // a placeholder while the correct size decodes below. + let stale = Self.nearestDecoded(urlString: url.absoluteString, preferring: maxPixels) + if stale !== cachedImage { + cachedImage = stale } - cachedImage = nil do { let (data, response) = try await ImageCache.thumbnailSession.data(from: url) guard !Task.isCancelled else { return } guard (response as? HTTPURLResponse).map({ $0.statusCode < 300 }) ?? true else { return } // Decode + GPU-prep on a background thread so the main actor never stalls. let image = await Task.detached(priority: .userInitiated) { - UIImage(data: data)?.preparingForDisplay() + Self.decode(data, maxPixels: maxPixels) }.value guard !Task.isCancelled, let image else { return } ImageCache.shared.set(image, for: key) @@ -129,6 +163,39 @@ struct CachedAsyncImage: View { // URLError.cancelled is expected on view disappear / url change — ignore silently. } } + + /// Best already-decoded version of this URL at another bucket size — larger + /// sizes first (sharper when scaled down), then smaller, then the legacy + /// unbucketed key. + private static func nearestDecoded(urlString: String, preferring maxPixels: Int?) -> UIImage? { + guard let maxPixels else { return nil } + let larger = buckets.filter { $0 > maxPixels } + let smaller = buckets.filter { $0 < maxPixels }.reversed() + for bucket in larger + smaller { + if let hit = ImageCache.shared.get(for: "\(urlString)#\(bucket)") { + return hit + } + } + return ImageCache.shared.get(for: urlString) + } + + // nonisolated: View members inherit @MainActor, but this pure function must run + // inside Task.detached — decoding on the main actor is the hitch we're avoiding. + private nonisolated static func decode(_ data: Data, maxPixels: Int?) -> UIImage? { + guard let raw = UIImage(data: data) else { return nil } + if let maxPixels { + let rawMax = max(raw.size.width, raw.size.height) * raw.scale + if rawMax > CGFloat(maxPixels) { + let ratio = CGFloat(maxPixels) / rawMax + let target = CGSize( + width: (raw.size.width * raw.scale * ratio).rounded(), + height: (raw.size.height * raw.scale * ratio).rounded() + ) + return raw.preparingThumbnail(of: target) ?? raw.preparingForDisplay() + } + } + return raw.preparingForDisplay() + } } struct CachedContentLoader { diff --git a/Django Files/Views/Lists/AlbumList.swift b/Django Files/Views/Lists/AlbumList.swift index b41fad3..20bc890 100644 --- a/Django Files/Views/Lists/AlbumList.swift +++ b/Django Files/Views/Lists/AlbumList.swift @@ -446,7 +446,7 @@ struct AlbumThumbnailGrid: View { LazyVGrid(columns: columns, spacing: 2) { ForEach(0..<4, id: \.self) { i in if i < thumbURLs.count { - CachedAsyncImage(url: thumbURLs[i]) { image in + CachedAsyncImage(url: thumbURLs[i], targetSize: 31) { image in image.resizable().scaledToFill() } placeholder: { Color.secondary.opacity(0.12) diff --git a/Django Files/Views/Lists/FileList.swift b/Django Files/Views/Lists/FileList.swift index 61faa5d..ebe9a16 100644 --- a/Django Files/Views/Lists/FileList.swift +++ b/Django Files/Views/Lists/FileList.swift @@ -108,102 +108,73 @@ class FileListManager: ObservableObject, FileListDelegate { return status } - func renameFile(fileID: Int, newName: String, onSuccess: (() -> Void)?) async -> Bool { - guard let serverInstance = server.wrappedValue, - let url = URL(string: serverInstance.url) else { - return false - } - - let api = DFAPI(url: url, token: serverInstance.token) - let status = await api.renameFile(fileID: fileID, name: newName, selectedServer: serverInstance) - if status { - withAnimation { - if let index = files.firstIndex(where: { $0.id == fileID }) { - var updatedFiles = files - - // Update the name - updatedFiles[index].name = newName - - // Update URLs that contain the filename - let file = updatedFiles[index] - - // Update raw URL - if let oldRawURL = URL(string: file.raw) { - let newRawURL = oldRawURL.deletingLastPathComponent().appendingPathComponent(newName) - updatedFiles[index].raw = newRawURL.absoluteString - } - - // Update thumb URL - if let oldThumbURL = URL(string: file.thumb) { - let newThumbURL = oldThumbURL.deletingLastPathComponent().appendingPathComponent(newName) - updatedFiles[index].thumb = newThumbURL.absoluteString - } - - // Update main URL - if let oldURL = URL(string: file.url) { - let newURL = oldURL.deletingLastPathComponent().appendingPathComponent(newName) - updatedFiles[index].url = newURL.absoluteString - } - - // Reassign the entire array to trigger a view update - files = updatedFiles + /// Apply `change` to the given files locally, reassigning the array once so a + /// single view update covers every mutation. + private func mutate(fileIDs: [Int], _ change: (inout DFFile) -> Void) { + withAnimation { + var updated = files + for id in fileIDs { + if let index = updated.firstIndex(where: { $0.id == id }) { + change(&updated[index]) } - onSuccess?() } + files = updated } - return status } - func setFilePassword(fileID: Int, password: String, onSuccess: (() -> Void)?) async -> Bool { + /// Shared server-edit path: POST the change, and mirror it locally on success. + private func applyEdit(fileIDs: [Int], changes: [String: Any], _ change: @escaping (inout DFFile) -> Void) async -> Bool { guard let serverInstance = server.wrappedValue, let url = URL(string: serverInstance.url) else { return false } - let api = DFAPI(url: url, token: serverInstance.token) - let status = await api.editFiles(fileIDs: [fileID], changes: ["password": password], selectedServer: serverInstance) + let status = await api.editFiles(fileIDs: fileIDs, changes: changes, selectedServer: serverInstance) if status { - withAnimation { - if let index = files.firstIndex(where: { $0.id == fileID }) { - var updatedFiles = files - updatedFiles[index].password = password - files = updatedFiles - } - onSuccess?() - } + mutate(fileIDs: fileIDs, change) } return status } - func setFilePrivate(fileID: Int, isPrivate: Bool, onSuccess: (() -> Void)?) async -> Bool { + func renameFile(fileID: Int, newName: String, onSuccess: (() -> Void)?) async -> Bool { guard let serverInstance = server.wrappedValue, let url = URL(string: serverInstance.url) else { return false } - + let api = DFAPI(url: url, token: serverInstance.token) - let status = await api.editFiles(fileIDs: [fileID], changes: ["private": isPrivate], selectedServer: serverInstance) + let status = await api.renameFile(fileID: fileID, name: newName, selectedServer: serverInstance) if status { - withAnimation { - if let index = files.firstIndex(where: { $0.id == fileID }) { - var updatedFiles = files - updatedFiles[index].private = isPrivate - files = updatedFiles + mutate(fileIDs: [fileID]) { file in + file.name = newName + // The raw/thumb/share URLs embed the filename — rewrite their last components + let urlKeyPaths: [WritableKeyPath] = [\.raw, \.thumb, \.url] + for keyPath in urlKeyPaths { + if let old = URL(string: file[keyPath: keyPath]) { + file[keyPath: keyPath] = old.deletingLastPathComponent() + .appendingPathComponent(newName).absoluteString + } } - onSuccess?() } + onSuccess?() } return status } + func setFilePassword(fileID: Int, password: String, onSuccess: (() -> Void)?) async -> Bool { + let status = await applyEdit(fileIDs: [fileID], changes: ["password": password]) { $0.password = password } + if status { onSuccess?() } + return status + } + + func setFilePrivate(fileID: Int, isPrivate: Bool, onSuccess: (() -> Void)?) async -> Bool { + let status = await applyEdit(fileIDs: [fileID], changes: ["private": isPrivate]) { $0.private = isPrivate } + if status { onSuccess?() } + return status + } + func updateFileAlbums(fileID: Int, albumIDs: [Int]) { - withAnimation { - if let index = files.firstIndex(where: { $0.id == fileID }) { - var updated = files - updated[index].albums = albumIDs - files = updated - } - } + mutate(fileIDs: [fileID]) { $0.albums = albumIDs } } func updateFilesAlbums(updates: [Int: [Int]]) { @@ -219,42 +190,12 @@ class FileListManager: ObservableObject, FileListDelegate { } func setFilesPrivate(fileIDs: [Int], isPrivate: Bool) async -> Bool { - guard let serverInstance = server.wrappedValue, - let url = URL(string: serverInstance.url) else { return false } - let api = DFAPI(url: url, token: serverInstance.token) - let status = await api.editFiles(fileIDs: fileIDs, changes: ["private": isPrivate], selectedServer: serverInstance) - if status { - withAnimation { - var updated = files - for id in fileIDs { - if let index = updated.firstIndex(where: { $0.id == id }) { - updated[index].private = isPrivate - } - } - files = updated - } - } - return status + await applyEdit(fileIDs: fileIDs, changes: ["private": isPrivate]) { $0.private = isPrivate } } func setFileExpiration(fileID: Int, expr: String, onSuccess: (() -> Void)?) async -> Bool { - guard let serverInstance = server.wrappedValue, - let url = URL(string: serverInstance.url) else { - return false - } - - let api = DFAPI(url: url, token: serverInstance.token) - let status = await api.editFiles(fileIDs: [fileID], changes: ["expr": expr], selectedServer: serverInstance) - if status { - withAnimation { - if let index = files.firstIndex(where: { $0.id == fileID }) { - var updatedFiles = files - updatedFiles[index].expr = expr - files = updatedFiles - } - onSuccess?() - } - } + let status = await applyEdit(fileIDs: [fileID], changes: ["expr": expr]) { $0.expr = expr } + if status { onSuccess?() } return status } } @@ -325,6 +266,7 @@ struct FileListView: View { @State private var mapFileCount: Int = 0 @State private var mapIsLoading: Bool = false + @State private var gridScrollAnchor = GridScrollAnchor() init(server: Binding, albumID: Int?, navigationPath: Binding, albumName: String?) { self.server = server @@ -342,8 +284,10 @@ struct FileListView: View { nonmutating set { fileListManager.files = newValue } } - private var filteredFiles: [DFFile] { - fileListManager.files + /// The user can act on (delete/edit) files they own; superusers own everything. + private func isOwned(_ file: DFFile) -> Bool { + (server.wrappedValue?.userID != nil && file.user == server.wrappedValue?.userID) + || server.wrappedValue?.superUser == true } private var filterTypeParam: String? { @@ -399,13 +343,6 @@ struct FileListView: View { return "list.bullet" } - private func thumbnailURL(file: DFFile) -> URL? { - guard let serverURL = server.wrappedValue.flatMap({ URL(string: $0.url) }) else { return nil } - var components = URLComponents(url: serverURL.appendingPathComponent("/raw/\(file.name)"), resolvingAgainstBaseURL: true) - components?.queryItems = [URLQueryItem(name: "thumb", value: "true")] - return components?.url - } - private func checkForDeepLinkTarget() { print("checkForDeepLinkTarget Called with target: \(String(describing: previewStateManager.deepLinkTargetFileID))") if let targetFileID = previewStateManager.deepLinkTargetFileID { @@ -442,57 +379,104 @@ struct FileListView: View { } } + // Photos-style density scaling: tighter gutters and squarer corners as cells shrink. + private var gridSpacing: CGFloat { + gridColumnCount >= 6 ? 1 : 2 + } + + private var gridCornerRadius: CGFloat { + max(0, 12 - CGFloat(gridColumnCount) * 1.5) + } + + // Zoomed-out grids show hundreds of cells per screen; scale the fetch size with + // density so pagination keeps up (server caps are generous, cap ours at 500). + private var pageSize: Int { + guard isGridView else { return 25 } + return min(500, max(25, gridColumnCount * gridColumnCount * 3)) + } + private var gridColumns: [GridItem] { - Array(repeating: GridItem(.flexible(), spacing: 2), count: gridColumnCount) + Array(repeating: GridItem(.flexible(), spacing: gridSpacing), count: gridColumnCount) } private var gridContent: some View { let showDetails = gridColumnCount <= 5 + let showContextMenus = gridColumnCount <= 8 let serverURL = resolvedServerURL - return PinchableGridContainer(gridColumnCount: $gridColumnCount) { topPad, bottomPad in + let prefetchThreshold = max(5, gridColumnCount * 3) + // Reference-box binding: scroll tracking writes go to the box (no view + // invalidation per row scrolled); the value is only read back when the column + // count swaps, letting the system keep the anchor item in place (Photos-style). + let anchorBinding = Binding( + get: { gridScrollAnchor.fileID }, + set: { gridScrollAnchor.fileID = $0 } + ) + return PinchableGridContainer(gridColumnCount: $gridColumnCount) { topPad, bottomPad, width in + let cellSize: CGFloat? = width > 0 + ? (width - gridSpacing * CGFloat(gridColumnCount - 1)) / CGFloat(gridColumnCount) + : nil + // Membership set built once per body evaluation — the previous per-cell + // `files.suffix(n).contains` scan cost O(n) on every single cell appear. + let prefetchIDs = Set(files.suffix(prefetchThreshold).map(\.id)) ScrollView { - LazyVGrid(columns: gridColumns, spacing: 2) { - ForEach(filteredFiles) { file in + LazyVGrid(columns: gridColumns, spacing: gridSpacing) { + ForEach(files) { file in let isSelected = selectedFileIDs.contains(file.id) - Button { + let item = FileGridItemView( + file: file, + serverURL: serverURL, + showDetails: showDetails, + naturalAspect: naturalAspect, + cornerRadius: gridCornerRadius, + targetSize: cellSize + ) + .equatable() + .contentShape(Rectangle()) + let base = Group { if isSelectMode { - toggleSelection(file: file) + item + .overlay(alignment: .topLeading) { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 22)) + .foregroundStyle(isSelected ? Color.accentColor : .white) + .shadow(color: .black.opacity(0.4), radius: 2, x: 0, y: 1) + .padding(6) + } + .opacity(isSelected ? 1.0 : 0.6) } else { - selectedFile = file - showingPreview = true + item } - } label: { - FileGridItemView( - file: file, - serverURL: serverURL, - showDetails: showDetails, - naturalAspect: naturalAspect - ) - .contentShape(Rectangle()) - .overlay(alignment: .topLeading) { + } + // Tap gesture instead of Button: press-tracking and accessibility + // wrappers add up across hundreds of visible cells. + let cell = base + .onTapGesture { if isSelectMode { - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .font(.system(size: 22)) - .foregroundStyle(isSelected ? Color.accentColor : .white) - .shadow(color: .black.opacity(0.4), radius: 2, x: 0, y: 1) - .padding(6) + toggleSelection(file: file) + } else { + selectedFile = file + showingPreview = true } } - .opacity(isSelectMode && !isSelected ? 0.6 : 1.0) - } - .buttonStyle(.plain) - .contextMenu { - if !isSelectMode { - fileContextMenu(for: file, isPrivate: file.private, expirationText: $expirationText, passwordText: $passwordText, fileNameText: $fileNameText) + .onAppear { + if hasNextPage && prefetchIDs.contains(file.id) { + loadNextPage() + } } - } - .onAppear { - if hasNextPage && fileListManager.files.suffix(5).contains(where: { $0.id == file.id }) { - loadNextPage() + + // The modifier itself installs a UIKit interaction per cell, so + // it must not be attached at all when zoomed far out (hundreds + // of visible cells) — an empty menu closure isn't enough. + if showContextMenus && !isSelectMode { + cell.contextMenu { + fileContextMenu(for: file, isPrivate: file.private, expirationText: $expirationText, passwordText: $passwordText, fileNameText: $fileNameText) } + } else { + cell } } } + .scrollTargetLayout() .padding(.top, topPad + 8) .padding(.bottom, bottomPad + 8) @@ -506,6 +490,7 @@ struct FileListView: View { .padding(.vertical, 8) } } + .scrollPosition(id: anchorBinding, anchor: .center) .ignoresSafeArea() .refreshable { Task { @@ -531,7 +516,7 @@ struct FileListView: View { gridContent } else { List { - ForEach(filteredFiles) { file in + ForEach(files) { file in let isSelected = selectedFileIDs.contains(file.id) Button { if isSelectMode { @@ -552,12 +537,12 @@ struct FileListView: View { if file.mime.starts(with: "image/") && !isSelectMode { FileRowView( file: $fileListManager.files[realIndex], - serverURL: server.wrappedValue.flatMap { URL(string: $0.url) } ?? URL(string: "https://localhost")! + serverURL: resolvedServerURL ) .contextMenu { fileContextMenu(for: file, isPrivate: file.private, expirationText: $expirationText, passwordText: $passwordText, fileNameText: $fileNameText) } preview: { - CachedAsyncImage(url: thumbnailURL(file: file)) { image in + CachedAsyncImage(url: file.thumbnailURL(on: resolvedServerURL)) { image in image .resizable() .scaledToFill() @@ -570,7 +555,7 @@ struct FileListView: View { } else { FileRowView( file: $fileListManager.files[realIndex], - serverURL: server.wrappedValue.flatMap { URL(string: $0.url) } ?? URL(string: "https://localhost")! + serverURL: resolvedServerURL ) .contextMenu { if !isSelectMode { @@ -583,8 +568,7 @@ struct FileListView: View { } .swipeActions(edge: .trailing, allowsFullSwipe: true) { if !isSelectMode { - let fileIsOwned = (server.wrappedValue?.userID != nil && file.user == server.wrappedValue?.userID) || (server.wrappedValue?.superUser == true) - if fileIsOwned { + if isOwned(file) { Button { fileIDsToDelete = [file.id] fileNameToDelete = file.name @@ -769,7 +753,7 @@ struct FileListView: View { } label: { Label("Select", systemImage: "checklist") } - .disabled(filteredFiles.isEmpty) + .disabled(files.isEmpty) } Divider() @@ -908,8 +892,7 @@ struct FileListView: View { } private func toggleSelection(file: DFFile) { - let owned = (server.wrappedValue?.userID != nil && file.user == server.wrappedValue?.userID) || (server.wrappedValue?.superUser == true) - guard owned else { return } + guard isOwned(file) else { return } if selectedFileIDs.contains(file.id) { selectedFileIDs.remove(file.id) } else { @@ -918,16 +901,13 @@ struct FileListView: View { } private var ownedSelectedIDs: [Int] { - filteredFiles - .filter { selectedFileIDs.contains($0.id) } - .filter { file in - (server.wrappedValue?.userID != nil && file.user == server.wrappedValue?.userID) || (server.wrappedValue?.superUser == true) - } + files + .filter { selectedFileIDs.contains($0.id) && isOwned($0) } .map(\.id) } private var selectedFiles: [DFFile] { - filteredFiles.filter { selectedFileIDs.contains($0.id) } + files.filter { selectedFileIDs.contains($0.id) } } @ViewBuilder @@ -935,9 +915,7 @@ struct FileListView: View { VStack(spacing: 0) { Divider() HStack { - let allOwned = filteredFiles.filter { file in - (server.wrappedValue?.userID != nil && file.user == server.wrappedValue?.userID) || (server.wrappedValue?.superUser == true) - } + let allOwned = files.filter { isOwned($0) } let allOwnedSelected = !allOwned.isEmpty && allOwned.allSatisfy { selectedFileIDs.contains($0.id) } Button { @@ -992,7 +970,7 @@ struct FileListView: View { guard !ids.isEmpty else { return } fileIDsToDelete = ids fileNameToDelete = ids.count == 1 - ? (filteredFiles.first(where: { $0.id == ids[0] })?.name ?? "") + ? (files.first(where: { $0.id == ids[0] })?.name ?? "") : "\(ids.count) files" showingDeleteConfirmation = true } label: { @@ -1011,7 +989,7 @@ struct FileListView: View { private func fileContextMenu(for file: DFFile, isPrivate: Bool, expirationText: Binding, passwordText: Binding, fileNameText: Binding) -> FileContextMenuButtons { var isPrivate: Bool = isPrivate - let isOwner = (server.wrappedValue?.userID != nil && file.user == server.wrappedValue?.userID) || (server.wrappedValue?.superUser == true) + let isOwner = isOwned(file) return FileContextMenuButtons( isPrivate: isPrivate, isOwner: isOwner, @@ -1112,8 +1090,12 @@ struct FileListView: View { guard hasNextPage else { return } guard !isLoading else { return } // Prevent multiple simultaneous loading requests isLoading = true + // Derive the page from what we already have so changing pageSize (pinch zoom) + // never skips server offsets — integer division only ever re-fetches overlap, + // which the append path deduplicates. + let nextPage = (files.count / pageSize) + 1 Task { - await fetchFiles(page: currentPage + 1, append: true) + await fetchFiles(page: nextPage, append: true) } } @@ -1122,7 +1104,9 @@ struct FileListView: View { isLoading = true errorMessage = nil currentPage = 1 - files = [] + // Don't clear here: page 1 replaces the array atomically on success (and the + // error path clears it), so the current content stays up during the refresh + // instead of tearing down and rebuilding the whole grid. await fetchFiles(page: currentPage) } @@ -1142,12 +1126,11 @@ struct FileListView: View { do { // Superuser with no user selected means "all users"; backend expects user=0 for that case let effectiveFilterUserID = filterUserID ?? (serverInstance.superUser ? 0 : nil) - let filesResponse = try await api.getFiles(page: page, album: albumID, selectedServer: serverInstance, filterUserID: effectiveFilterUserID, filterType: filterTypeParam, ordering: sessionManager.supportsOrdering ? sortOption : nil, search: nil) + let filesResponse = try await api.getFiles(page: page, pageSize: pageSize, album: albumID, selectedServer: serverInstance, filterUserID: effectiveFilterUserID, filterType: filterTypeParam, ordering: sessionManager.supportsOrdering ? sortOption : nil, search: nil) if append { // Only append new files that aren't already in the list - let newFiles = filesResponse.files.filter { newFile in - !files.contains { $0.id == newFile.id } - } + let existingIDs = Set(files.map(\.id)) + let newFiles = filesResponse.files.filter { !existingIDs.contains($0.id) } files.append(contentsOf: newFiles) } else { files = filesResponse.files @@ -1222,38 +1205,69 @@ struct FileListView: View { } +// Plain reference type on purpose: scrollPosition(id:) writes on every row scrolled, +// and holding the value outside @State keeps those writes from re-evaluating the +// (large) file grid body. +private final class GridScrollAnchor { + var fileID: Int? +} + private struct PinchableGridContainer: View { + static var maxColumns: Int { 25 } + @Binding var gridColumnCount: Int - @ViewBuilder let content: (_ topPad: CGFloat, _ bottomPad: CGFloat) -> Content - @State private var gestureScale: CGFloat = 1.0 - @State private var scaleAnchor: UnitPoint = .center - @State private var anchorCaptured: Bool = false + @ViewBuilder let content: (_ topPad: CGFloat, _ bottomPad: CGFloat, _ width: CGFloat) -> Content @State private var topPadding: CGFloat = 0 @State private var bottomPadding: CGFloat = 0 @State private var containerSize: CGSize = .zero var body: some View { - content(topPadding, bottomPadding) - // scaleEffect is applied here — outside the content closure — so gestureScale - // changes drive a pure CALayer transform without re-evaluating the view tree. - .scaleEffect(x: gestureScale, y: gestureScale, anchor: scaleAnchor) - .background { - GeometryReader { geo in - Color.clear - .onAppear { - topPadding = geo.safeAreaInsets.top - bottomPadding = geo.safeAreaInsets.bottom - containerSize = geo.size - } - .onChange(of: geo.safeAreaInsets) { _, insets in - topPadding = insets.top - bottomPadding = insets.bottom - } - .onChange(of: geo.size) { _, size in - containerSize = size - } - } + PinchZoomLayer(gridColumnCount: $gridColumnCount, containerSize: containerSize) { + content(topPadding, bottomPadding, containerSize.width) + } + .background { + GeometryReader { geo in + Color.clear + .onAppear { + topPadding = geo.safeAreaInsets.top + bottomPadding = geo.safeAreaInsets.bottom + containerSize = geo.size + } + .onChange(of: geo.safeAreaInsets) { _, insets in + topPadding = insets.top + bottomPadding = insets.bottom + } + .onChange(of: geo.size) { _, size in + containerSize = size + } } + } + } +} + +// Owns all per-frame gesture state, and holds `content` as a pre-built value rather +// than a closure: pinch frames re-run only this body, the stored grid subtree diffs +// as unchanged, and the scale change stays a pure CALayer transform. When the state +// lived beside the content closure, every gesture frame re-evaluated the entire +// LazyVGrid ForEach. +private struct PinchZoomLayer: View { + @Binding var gridColumnCount: Int + let containerSize: CGSize + let content: Content + + @State private var gestureScale: CGFloat = 1.0 + @State private var scaleAnchor: UnitPoint = .center + @State private var anchorCaptured: Bool = false + + init(gridColumnCount: Binding, containerSize: CGSize, @ViewBuilder content: () -> Content) { + self._gridColumnCount = gridColumnCount + self.containerSize = containerSize + self.content = content() + } + + var body: some View { + content + .scaleEffect(x: gestureScale, y: gestureScale, anchor: scaleAnchor) // highPriorityGesture: MagnifyGesture only activates on two fingers, so // single-finger scrolls and taps pass through naturally. When two fingers // are detected, this wins over child button gestures — preventing accidental @@ -1269,12 +1283,20 @@ private struct PinchableGridContainer: View { } anchorCaptured = true } - gestureScale = max(0.4, min(3.0, value.magnification)) + gestureScale = max(0.2, min(3.0, value.magnification)) } .onEnded { value in - let newCount = max(1, min(10, Int((CGFloat(gridColumnCount) / value.magnification).rounded()))) - withAnimation(.easeOut(duration: 0.2)) { - gridColumnCount = newCount + // Photos-style seamless reflow: swap the column count with NO + // layout animation (animating it relayouts every visible cell + // per frame — the zoom lag), but pick the residual scale that + // makes the new layout's cell size exactly match what's on + // screen, then settle that small correction back to 1. + let startCount = gridColumnCount + let finalScale = max(0.2, min(3.0, value.magnification)) + let newCount = max(1, min(PinchableGridContainer.maxColumns, Int((CGFloat(startCount) / finalScale).rounded()))) + gridColumnCount = newCount + gestureScale = finalScale * CGFloat(newCount) / CGFloat(startCount) + withAnimation(.easeOut(duration: 0.18)) { gestureScale = 1.0 } anchorCaptured = false @@ -1283,24 +1305,35 @@ private struct PinchableGridContainer: View { } } -struct FileGridItemView: View { +struct FileGridItemView: View, Equatable { let file: DFFile let serverURL: URL - let thumbnailURL: URL var showDetails: Bool = true var naturalAspect: Bool = false + var cornerRadius: CGFloat = 8 + var targetSize: CGFloat? = nil + + // Compared via .equatable() at the call site so list-wide invalidations + // (page appends, selection changes) skip the body of every unchanged cell. + // Only fields that affect rendering participate. + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.file.id == rhs.file.id + && lhs.file.name == rhs.file.name + && lhs.file.mime == rhs.file.mime + && lhs.file.private == rhs.file.private + && lhs.file.password == rhs.file.password + && lhs.file.expr == rhs.file.expr + && lhs.serverURL == rhs.serverURL + && lhs.showDetails == rhs.showDetails + && lhs.naturalAspect == rhs.naturalAspect + && lhs.cornerRadius == rhs.cornerRadius + && lhs.targetSize == rhs.targetSize + } - init(file: DFFile, serverURL: URL, showDetails: Bool = true, naturalAspect: Bool = false) { - self.file = file - self.serverURL = serverURL - self.showDetails = showDetails - self.naturalAspect = naturalAspect - var components = URLComponents( - url: serverURL.appendingPathComponent("/raw/\(file.name)"), - resolvingAgainstBaseURL: true - ) - components?.queryItems = [URLQueryItem(name: "thumb", value: "true")] - self.thumbnailURL = components?.url ?? serverURL + // Computed in body (pruned by Equatable) instead of init: URL parsing ran for + // every visible cell on every list-wide re-evaluation. + private var thumbnailURL: URL { + file.thumbnailURL(on: serverURL) } private var isMedia: Bool { @@ -1316,11 +1349,25 @@ struct FileGridItemView: View { return "doc.fill" } + private var hasBadge: Bool { + showDetails && (file.private || file.password != "" || file.expr != "") + } + var body: some View { - if naturalAspect && isMedia { - naturalMediaCell + let core = Group { + if naturalAspect && isMedia { + naturalMediaCell + } else { + squareCell + } + } + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + // Badge overlay attached only when there's something to draw — a constant + // empty overlay still costs a node on every one of hundreds of cells. + if hasBadge { + core.overlay(alignment: .bottomTrailing) { statusBadge } } else { - squareCell + core } } @@ -1330,7 +1377,7 @@ struct FileGridItemView: View { .overlay { ZStack(alignment: .bottom) { if isMedia { - CachedAsyncImage(url: thumbnailURL) { image in + CachedAsyncImage(url: thumbnailURL, targetSize: targetSize) { image in image.resizable().scaledToFill() } placeholder: { Color(.systemGray5) @@ -1339,7 +1386,9 @@ struct FileGridItemView: View { Color(.systemGray5) .overlay { Image(systemName: getIcon()) - .font(.system(size: 30)) + // Scale to the cell — a fixed 30pt symbol overflows + // (and wastes raster work on) tiny zoomed-out cells. + .font(.system(size: min(30, (targetSize ?? 75) * 0.4))) .foregroundStyle(.secondary) } } @@ -1356,38 +1405,30 @@ struct FileGridItemView: View { .background(.black.opacity(0.5)) } } - .clipped() } - .overlay(alignment: .bottomTrailing) { statusBadge } - .clipShape(RoundedRectangle(cornerRadius: 8)) } private var naturalMediaCell: some View { - CachedAsyncImage(url: thumbnailURL) { image in + CachedAsyncImage(url: thumbnailURL, targetSize: targetSize) { image in image.resizable().scaledToFit() } placeholder: { Color(.systemGray5) .aspectRatio(4/3, contentMode: .fit) } - .overlay(alignment: .bottomTrailing) { statusBadge } - .clipShape(RoundedRectangle(cornerRadius: 8)) } - @ViewBuilder private var statusBadge: some View { - if showDetails && (file.private || file.password != "" || file.expr != "") { - HStack(spacing: 2) { - if file.private { Image(systemName: "lock.fill").font(.system(size: 8)) } - if file.password != "" { Image(systemName: "key.fill").font(.system(size: 8)) } - if file.expr != "" { Image(systemName: "clock.fill").font(.system(size: 8)) } - } - .foregroundStyle(.white) - .padding(.horizontal, 4) - .padding(.vertical, 3) - .background(.black.opacity(0.55)) - .clipShape(RoundedRectangle(cornerRadius: 4)) - .padding(4) + HStack(spacing: 2) { + if file.private { Image(systemName: "lock.fill").font(.system(size: 8)) } + if file.password != "" { Image(systemName: "key.fill").font(.system(size: 8)) } + if file.expr != "" { Image(systemName: "clock.fill").font(.system(size: 8)) } } + .foregroundStyle(.white) + .padding(.horizontal, 4) + .padding(.vertical, 3) + .background(.black.opacity(0.55)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .padding(4) } } diff --git a/Django Files/Views/Lists/FileRow.swift b/Django Files/Views/Lists/FileRow.swift index 3218b18..ee889fd 100644 --- a/Django Files/Views/Lists/FileRow.swift +++ b/Django Files/Views/Lists/FileRow.swift @@ -36,16 +36,14 @@ struct FileRowView: View { } private var thumbnailURL: URL { - var components = URLComponents(url: serverURL.appendingPathComponent("/raw/\(file.name)"), resolvingAgainstBaseURL: true) - components?.queryItems = [URLQueryItem(name: "thumb", value: "true")] - return components?.url ?? serverURL + file.thumbnailURL(on: serverURL) } var body: some View { HStack(alignment: .center) { VStack(spacing: 0) { if file.mime.hasPrefix("image/") || file.mime.hasPrefix("video/") { - CachedAsyncImage(url: thumbnailURL) { image in + CachedAsyncImage(url: thumbnailURL, targetSize: 64) { image in image .resizable() .scaledToFill() diff --git a/Django Files/Views/Map/FileMapView.swift b/Django Files/Views/Map/FileMapView.swift index f80625b..c587ebd 100644 --- a/Django Files/Views/Map/FileMapView.swift +++ b/Django Files/Views/Map/FileMapView.swift @@ -528,7 +528,7 @@ struct MapClusterPin: View { ZStack(alignment: .topTrailing) { Group { if let url = thumbnailURLs.first { - CachedAsyncImage(url: url) { img in + CachedAsyncImage(url: url, targetSize: 48) { img in img.resizable().scaledToFill() } placeholder: { Color(.systemGray5).overlay { @@ -611,7 +611,7 @@ struct ClusterMapCallout: View { } } else { ForEach(Array(displayURLs.enumerated()), id: \.offset) { idx, url in - CachedAsyncImage(url: url) { img in + CachedAsyncImage(url: url, targetSize: 280) { img in img.resizable().scaledToFill() } placeholder: { Color(.systemGray5) @@ -686,7 +686,7 @@ struct FileMapPin: View { Button { showingCallout = true } label: { ZStack { if showThumb, let url = thumbnailURL { - CachedAsyncImage(url: url) { img in + CachedAsyncImage(url: url, targetSize: 44) { img in img.resizable().scaledToFill() } placeholder: { Color(.systemGray5) @@ -737,7 +737,7 @@ struct FileMapCallout: View { Group { if file.mime.hasPrefix("image/") || file.mime.hasPrefix("video/"), let url = thumbnailURL { - CachedAsyncImage(url: url) { img in + CachedAsyncImage(url: url, targetSize: 280) { img in img.resizable().scaledToFill() } placeholder: { Color(.systemGray5) diff --git a/Django Files/Views/Settings/SettingsView.swift b/Django Files/Views/Settings/SettingsView.swift index cdfc95c..3c4fc1e 100644 --- a/Django Files/Views/Settings/SettingsView.swift +++ b/Django Files/Views/Settings/SettingsView.swift @@ -30,7 +30,7 @@ struct SettingsView: View { } label: { HStack(spacing: 12) { if let avatarUrl = server.avatarUrl { - CachedAsyncImage(url: avatarUrl) { image in + CachedAsyncImage(url: avatarUrl, targetSize: 44) { image in image .resizable() .aspectRatio(contentMode: .fill)