import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
struct ContentView: View {
|
|
@EnvironmentObject var model: AppModel
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
if !model.encoderReady {
|
|
banner("cjxl not found — run: brew install jpeg-xl", color: .red)
|
|
}
|
|
controls
|
|
Divider()
|
|
dropZone
|
|
Divider()
|
|
footer
|
|
}
|
|
}
|
|
|
|
private var controls: some View {
|
|
HStack(spacing: 16) {
|
|
Toggle("Lossless", isOn: $model.lossless)
|
|
if !model.lossless {
|
|
HStack {
|
|
Text("Quality")
|
|
Slider(value: $model.distance, in: 0.5...3.0)
|
|
.frame(width: 120)
|
|
Text(String(format: "d%.1f", model.distance)).monospacedDigit()
|
|
}
|
|
}
|
|
Stepper("Effort \(model.effort)", value: $model.effort, in: 1...9)
|
|
Toggle("Keep original", isOn: $model.keepOriginal)
|
|
Spacer()
|
|
}.padding(10)
|
|
}
|
|
|
|
private var dropZone: some View {
|
|
ZStack {
|
|
if model.items.isEmpty {
|
|
VStack(spacing: 6) {
|
|
Image(systemName: "square.and.arrow.down").font(.system(size: 34))
|
|
Text("Drop JPG / PNG / GIF here").foregroundColor(.secondary)
|
|
}
|
|
} else {
|
|
List(model.items) { ImageRow(item: $0) }
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.onDrop(of: [UTType.fileURL], isTargeted: nil) { providers in
|
|
for p in providers {
|
|
_ = p.loadObject(ofClass: URL.self) { url, _ in
|
|
if let url { DispatchQueue.main.async { model.add(urls: [url]) } }
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
|
|
private var footer: some View {
|
|
HStack {
|
|
if model.busy { ProgressView().scaleEffect(0.6); Text("Compressing…") }
|
|
Spacer()
|
|
Text("Saved \(human(model.totalSaved))").bold()
|
|
}.padding(8)
|
|
}
|
|
|
|
private func banner(_ s: String, color: Color) -> some View {
|
|
Text(s).foregroundColor(.white).frame(maxWidth: .infinity)
|
|
.padding(6).background(color)
|
|
}
|
|
}
|
|
|
|
struct ImageRow: View {
|
|
@ObservedObject var item: ImageItem
|
|
var body: some View {
|
|
HStack {
|
|
icon
|
|
Text(item.url.lastPathComponent).lineLimit(1)
|
|
Spacer()
|
|
switch item.status {
|
|
case .done:
|
|
Text("\(human(item.originalBytes)) → \(human(item.newBytes))").foregroundColor(.secondary)
|
|
Text(String(format: "−%.0f%%", item.savedFraction * 100)).foregroundColor(.green).bold()
|
|
case .failed: Text(item.message).foregroundColor(.red).lineLimit(1)
|
|
case .skipped: Text("no gain").foregroundColor(.secondary)
|
|
case .working: ProgressView().scaleEffect(0.5)
|
|
case .pending: Text("queued").foregroundColor(.secondary)
|
|
}
|
|
}.monospacedDigit()
|
|
}
|
|
@ViewBuilder var icon: some View {
|
|
switch item.status {
|
|
case .done: Image(systemName: "checkmark.circle.fill").foregroundColor(.green)
|
|
case .failed: Image(systemName: "xmark.circle.fill").foregroundColor(.red)
|
|
default: Image(systemName: "photo")
|
|
}
|
|
}
|
|
}
|