NavidromeApp/iOS/Views/Companion/BatchUploadView.swift

260 lines
9.8 KiB
Swift
Raw Normal View History

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)
}
}
}