import Foundation
|
|
import SwiftUI
|
|
|
|
/// One row in the drop list. ObservableObject so each row updates independently
|
|
/// as its async compression finishes.
|
|
final class ImageItem: ObservableObject, Identifiable {
|
|
let id = UUID()
|
|
let url: URL
|
|
@Published var status: Status = .pending
|
|
@Published var originalBytes: Int = 0
|
|
@Published var newBytes: Int = 0
|
|
@Published var message: String = ""
|
|
|
|
enum Status { case pending, working, done, failed, skipped }
|
|
|
|
init(url: URL) {
|
|
self.url = url
|
|
self.originalBytes = (try? FileManager.default
|
|
.attributesOfItem(atPath: url.path)[.size] as? Int) ?? 0
|
|
}
|
|
|
|
var savedFraction: Double {
|
|
originalBytes > 0 && newBytes > 0 ? 1 - Double(newBytes) / Double(originalBytes) : 0
|
|
}
|
|
}
|
|
|
|
final class AppModel: ObservableObject {
|
|
@Published var items: [ImageItem] = []
|
|
@Published var lossless = false
|
|
@Published var distance = 1.0
|
|
@Published var effort = 7
|
|
@Published var keepOriginal = true
|
|
@Published var busy = false
|
|
|
|
var totalSaved: Int { items.reduce(0) { $0 + max(0, $1.originalBytes - $1.newBytes) } }
|
|
var encoderReady: Bool { Compressor.cjxlPath != nil }
|
|
|
|
func add(urls: [URL]) {
|
|
for url in urls {
|
|
let ext = url.pathExtension.lowercased()
|
|
guard Compressor.supportedExtensions.contains(ext) else { continue }
|
|
guard !items.contains(where: { $0.url == url }) else { continue }
|
|
items.append(ImageItem(url: url))
|
|
}
|
|
run()
|
|
}
|
|
|
|
func run() {
|
|
guard !busy else { return }
|
|
let opts = Compressor.Options(lossless: lossless, distance: distance,
|
|
effort: effort, keepOriginal: keepOriginal)
|
|
let pending = items.filter { $0.status == .pending }
|
|
guard !pending.isEmpty else { return }
|
|
busy = true
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
for item in pending {
|
|
DispatchQueue.main.async { item.status = .working }
|
|
do {
|
|
let r = try Compressor.compress(item.url, options: opts)
|
|
DispatchQueue.main.async {
|
|
item.newBytes = r.newBytes
|
|
item.status = r.savedFraction > 0 ? .done : .skipped
|
|
}
|
|
} catch {
|
|
DispatchQueue.main.async {
|
|
item.status = .failed
|
|
item.message = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
DispatchQueue.main.async { self.busy = false }
|
|
}
|
|
}
|
|
}
|
|
|
|
func human(_ bytes: Int) -> String {
|
|
ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
|
|
}
|