Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 5 additions & 1 deletion Django Files/API/DFAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,18 @@ 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
self.token = token
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 {
Expand Down
15 changes: 13 additions & 2 deletions Django Files/API/Files.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
99 changes: 83 additions & 16 deletions Django Files/Utils/ImageCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Content: View, Placeholder: View>: 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)
Expand All @@ -129,6 +163,39 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: 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 {
Expand Down
2 changes: 1 addition & 1 deletion Django Files/Views/Lists/AlbumList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading