260 lines
9.8 KiB
Swift
260 lines
9.8 KiB
Swift
|
|
import SwiftUI
|
||
|
|
import UniformTypeIdentifiers
|
||
|
|
|
||
|
|
struct BatchUploadView: View {
|
||
|
|
@ObservedObject private var manager = ZipImportManager.shared
|
||
|
|
@ObservedObject private var settings = CompanionSettings.shared
|
||
|
|
|
||
|
|
@State private var showFilePicker = false
|
||
|
|
@State private var album = ""
|
||
|
|
@State private var artist = ""
|
||
|
|
@State private var albumArtist = ""
|
||
|
|
@State private var genre = ""
|
||
|
|
@State private var year = ""
|
||
|
|
@State private var keepOffline = false
|
||
|
|
|
||
|
|
private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333)
|
||
|
|
|
||
|
|
var body: some View {
|
||
|
|
NavigationStack {
|
||
|
|
List {
|
||
|
|
if !settings.isEnabled {
|
||
|
|
Section {
|
||
|
|
VStack(spacing: 8) {
|
||
|
|
Image(systemName: "exclamationmark.triangle")
|
||
|
|
.font(.system(size: 28))
|
||
|
|
.foregroundColor(.yellow)
|
||
|
|
Text("Companion API Not Configured")
|
||
|
|
.font(.system(size: 15, weight: .medium))
|
||
|
|
.foregroundColor(.white)
|
||
|
|
Text("Set your Companion API host and port in Settings to upload tracks.")
|
||
|
|
.font(.system(size: 13))
|
||
|
|
.foregroundColor(.gray)
|
||
|
|
.multilineTextAlignment(.center)
|
||
|
|
}
|
||
|
|
.frame(maxWidth: .infinity)
|
||
|
|
.padding(.vertical, 12)
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
// Step 1: Import
|
||
|
|
importSection
|
||
|
|
|
||
|
|
// Step 2: Extracted files
|
||
|
|
if !manager.extractedFiles.isEmpty {
|
||
|
|
metadataSection
|
||
|
|
fileListSection
|
||
|
|
uploadSection
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.navigationTitle("Upload Music")
|
||
|
|
.navigationBarTitleDisplayMode(.inline)
|
||
|
|
.toolbar {
|
||
|
|
if manager.isUploading {
|
||
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||
|
|
Button("Cancel") {
|
||
|
|
manager.cancelAll()
|
||
|
|
}
|
||
|
|
.foregroundColor(.red)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
.fileImporter(
|
||
|
|
isPresented: $showFilePicker,
|
||
|
|
allowedContentTypes: [UTType.zip, UTType.archive],
|
||
|
|
allowsMultipleSelection: false
|
||
|
|
) { result in
|
||
|
|
switch result {
|
||
|
|
case .success(let urls):
|
||
|
|
if let url = urls.first {
|
||
|
|
manager.extractZip(at: url)
|
||
|
|
}
|
||
|
|
case .failure(let error):
|
||
|
|
DebugLogger.shared.log("File picker error: \(error.localizedDescription)", category: "Upload")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Import Section
|
||
|
|
|
||
|
|
private var importSection: some View {
|
||
|
|
Section {
|
||
|
|
Button(action: {
|
||
|
|
manager.reset()
|
||
|
|
showFilePicker = true
|
||
|
|
}) {
|
||
|
|
HStack {
|
||
|
|
Image(systemName: "doc.zipper")
|
||
|
|
.font(.system(size: 20))
|
||
|
|
.foregroundColor(accentPink)
|
||
|
|
VStack(alignment: .leading, spacing: 2) {
|
||
|
|
Text("Import Zip Archive")
|
||
|
|
.font(.system(size: 15, weight: .medium))
|
||
|
|
.foregroundColor(.white)
|
||
|
|
Text("Select a .zip containing audio files")
|
||
|
|
.font(.system(size: 12))
|
||
|
|
.foregroundColor(.gray)
|
||
|
|
}
|
||
|
|
Spacer()
|
||
|
|
Image(systemName: "chevron.right")
|
||
|
|
.font(.system(size: 12))
|
||
|
|
.foregroundColor(.gray)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if manager.isExtracting {
|
||
|
|
HStack(spacing: 12) {
|
||
|
|
ProgressView()
|
||
|
|
.tint(accentPink)
|
||
|
|
Text(manager.extractionProgress)
|
||
|
|
.font(.system(size: 13))
|
||
|
|
.foregroundColor(.gray)
|
||
|
|
}
|
||
|
|
} else if !manager.extractionProgress.isEmpty && manager.extractedFiles.isEmpty {
|
||
|
|
Text(manager.extractionProgress)
|
||
|
|
.font(.system(size: 13))
|
||
|
|
.foregroundColor(.gray)
|
||
|
|
}
|
||
|
|
} header: {
|
||
|
|
Text("Import")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Metadata Section
|
||
|
|
|
||
|
|
private var metadataSection: some View {
|
||
|
|
Section {
|
||
|
|
TextField("Album", text: $album)
|
||
|
|
.textInputAutocapitalization(.words)
|
||
|
|
TextField("Artist", text: $artist)
|
||
|
|
.textInputAutocapitalization(.words)
|
||
|
|
TextField("Album Artist", text: $albumArtist)
|
||
|
|
.textInputAutocapitalization(.words)
|
||
|
|
TextField("Genre", text: $genre)
|
||
|
|
.textInputAutocapitalization(.words)
|
||
|
|
TextField("Year", text: $year)
|
||
|
|
.keyboardType(.numberPad)
|
||
|
|
|
||
|
|
Toggle("Keep Offline After Upload", isOn: $keepOffline)
|
||
|
|
.tint(accentPink)
|
||
|
|
} header: {
|
||
|
|
Text("Batch Tags")
|
||
|
|
} footer: {
|
||
|
|
Text(keepOffline
|
||
|
|
? "Uploaded files will be kept in local storage for offline playback."
|
||
|
|
: "Uploaded files will be deleted locally. Re-download from server for offline use.")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - File List
|
||
|
|
|
||
|
|
private var fileListSection: some View {
|
||
|
|
Section {
|
||
|
|
ForEach(manager.extractedFiles) { file in
|
||
|
|
HStack(spacing: 12) {
|
||
|
|
// Status indicator
|
||
|
|
statusIcon(for: file.filename)
|
||
|
|
.frame(width: 24)
|
||
|
|
|
||
|
|
VStack(alignment: .leading, spacing: 2) {
|
||
|
|
Text(file.filename)
|
||
|
|
.font(.system(size: 14, weight: .medium))
|
||
|
|
.foregroundColor(.white)
|
||
|
|
.lineLimit(1)
|
||
|
|
Text(ByteCountFormatter.string(fromByteCount: file.fileSize, countStyle: .file))
|
||
|
|
.font(.system(size: 11))
|
||
|
|
.foregroundColor(.gray)
|
||
|
|
}
|
||
|
|
|
||
|
|
Spacer()
|
||
|
|
|
||
|
|
// Progress for uploading
|
||
|
|
if case .uploading(let pct) = manager.uploadStates[file.filename] {
|
||
|
|
Text("\(Int(pct * 100))%")
|
||
|
|
.font(.system(size: 11, design: .monospaced))
|
||
|
|
.foregroundColor(accentPink)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} header: {
|
||
|
|
Text("\(manager.extractedFiles.count) Tracks")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Upload Section
|
||
|
|
|
||
|
|
private var uploadSection: some View {
|
||
|
|
Section {
|
||
|
|
if manager.isUploading {
|
||
|
|
VStack(spacing: 8) {
|
||
|
|
ProgressView(value: manager.uploadProgress)
|
||
|
|
.tint(accentPink)
|
||
|
|
|
||
|
|
let completed = manager.uploadStates.values.filter { $0 == .completed }.count
|
||
|
|
let failed = manager.uploadStates.values.filter {
|
||
|
|
if case .failed = $0 { return true }
|
||
|
|
return false
|
||
|
|
}.count
|
||
|
|
|
||
|
|
Text("Uploading: \(completed)/\(manager.extractedFiles.count) complete" + (failed > 0 ? ", \(failed) failed" : ""))
|
||
|
|
.font(.system(size: 12))
|
||
|
|
.foregroundColor(.gray)
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
Button(action: {
|
||
|
|
let meta = UploadMetadata(
|
||
|
|
title: "", // Per-file from filename
|
||
|
|
artist: artist,
|
||
|
|
album: album,
|
||
|
|
albumArtist: albumArtist.isEmpty ? artist : albumArtist,
|
||
|
|
genre: genre,
|
||
|
|
year: year,
|
||
|
|
trackNumber: "" // Auto-assigned per file
|
||
|
|
)
|
||
|
|
manager.startBatchUpload(metadata: meta, keepOffline: keepOffline)
|
||
|
|
}) {
|
||
|
|
HStack {
|
||
|
|
Image(systemName: "icloud.and.arrow.up")
|
||
|
|
.foregroundColor(.white)
|
||
|
|
Text("Upload All")
|
||
|
|
.font(.system(size: 15, weight: .semibold))
|
||
|
|
.foregroundColor(.white)
|
||
|
|
}
|
||
|
|
.frame(maxWidth: .infinity)
|
||
|
|
.padding(.vertical, 8)
|
||
|
|
}
|
||
|
|
.listRowBackground(accentPink)
|
||
|
|
.disabled(artist.isEmpty || album.isEmpty)
|
||
|
|
}
|
||
|
|
} footer: {
|
||
|
|
if !manager.isUploading && (artist.isEmpty || album.isEmpty) {
|
||
|
|
Text("Artist and Album are required.")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// MARK: - Status Icon
|
||
|
|
|
||
|
|
@ViewBuilder
|
||
|
|
private func statusIcon(for filename: String) -> some View {
|
||
|
|
switch manager.uploadStates[filename] {
|
||
|
|
case .pending, .none:
|
||
|
|
Image(systemName: "circle")
|
||
|
|
.font(.system(size: 14))
|
||
|
|
.foregroundColor(.gray)
|
||
|
|
case .uploading:
|
||
|
|
ProgressView()
|
||
|
|
.scaleEffect(0.7)
|
||
|
|
case .completed:
|
||
|
|
Image(systemName: "checkmark.circle.fill")
|
||
|
|
.font(.system(size: 14))
|
||
|
|
.foregroundColor(.green)
|
||
|
|
case .failed:
|
||
|
|
Image(systemName: "xmark.circle.fill")
|
||
|
|
.font(.system(size: 14))
|
||
|
|
.foregroundColor(.red)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|