import SwiftUI /// Batch-edit tags across one or more albums. /// Fetches full album data for each ID, then applies changes to all tracks. struct MultiAlbumEditorSheet: View { let albumIds: [String] var onDismiss: (() -> Void)? = nil @Environment(\.dismiss) private var dismiss @EnvironmentObject var serverManager: ServerManager @State private var albums: [AlbumWithSongs] = [] @State private var isLoadingAlbums = true @State private var albumName: String = "" @State private var albumArtist: String = "" @State private var artist: String = "" @State private var genre: String = "" @State private var year: String = "" @State private var applyArtistToAll = false @State private var isSaving = false @State private var progress: Double = 0 @State private var resultMessage: String? @State private var failedTracks: [String] = [] @ObservedObject private var settings = CompanionSettings.shared private let api = CompanionAPIService() private let accentPink = Color(red: 1.0, green: 0.176, blue: 0.333) private var allSongs: [Song] { albums.flatMap { $0.song ?? [] } } var body: some View { NavigationStack { Group { if !settings.isEnabled { noApiView } else if isLoadingAlbums { loadingView } else { editorForm } } .navigationTitle(albumIds.count == 1 ? "Edit Album" : "Edit \(albumIds.count) Albums") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button("Cancel") { onDismiss?() dismiss() } .foregroundColor(.gray) } ToolbarItem(placement: .navigationBarTrailing) { if settings.isEnabled && !isLoadingAlbums { Button("Apply", action: applyBatch) .fontWeight(.semibold) .foregroundColor(accentPink) .disabled(isSaving || albumName.isEmpty) } } } .task { await loadAlbums() } } } // MARK: - Loading private var loadingView: some View { VStack(spacing: 16) { ProgressView() .tint(accentPink) Text("Loading \(albumIds.count) album\(albumIds.count == 1 ? "" : "s")...") .font(.system(size: 14)) .foregroundColor(.gray) } .frame(maxWidth: .infinity, maxHeight: .infinity) } private var noApiView: some View { VStack(spacing: 12) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 32)) .foregroundColor(.yellow) Text("Companion API Required") .font(.system(size: 16, weight: .medium)) Text("Enable the Companion API in Settings to edit tags.") .font(.system(size: 13)) .foregroundColor(.gray) .multilineTextAlignment(.center) } .padding(40) } // MARK: - Editor Form private var editorForm: some View { List { // Scope summary Section { HStack { Text("Albums") .foregroundColor(.gray) Spacer() Text("\(albums.count)") .foregroundColor(.white) } HStack { Text("Total Tracks") .foregroundColor(.gray) Spacer() Text("\(allSongs.count)") .foregroundColor(.white) } // Show album names ForEach(albums) { album in HStack(spacing: 8) { AsyncCoverArt(coverArtId: album.coverArt, size: 40) .frame(width: 32, height: 32) .cornerRadius(3) VStack(alignment: .leading, spacing: 1) { Text(album.name) .font(.system(size: 13)) .foregroundColor(.white) .lineLimit(1) Text("\(album.artist ?? "Unknown") · \(album.song?.count ?? 0) tracks") .font(.system(size: 11)) .foregroundColor(.gray) } } } } header: { Text("Selected Albums") } footer: { Text("Changes apply to ALL tracks across all selected albums.") } // Tag fields Section { editField("Album", text: $albumName) editField("Album Artist", text: $albumArtist) editField("Genre", text: $genre) editField("Year", text: $year, keyboard: .numberPad) } header: { Text("Album Tags") } // Artist override Section { Toggle("Set same artist on all tracks", isOn: $applyArtistToAll) .tint(accentPink) if applyArtistToAll { editField("Artist", text: $artist) } } header: { Text("Artist Override") } footer: { if applyArtistToAll { Text("Every track gets the same artist — useful for fixing compilations that split into per-artist albums.") } else { Text("Each track keeps its current artist.") } } // Track preview Section { ForEach(allSongs, id: \.id) { song in HStack(spacing: 8) { Text("\(song.track ?? 0)") .font(.system(size: 11, design: .monospaced)) .foregroundColor(.gray) .frame(width: 20) VStack(alignment: .leading, spacing: 1) { Text(song.title) .font(.system(size: 13)) .foregroundColor(.white) .lineLimit(1) Text(applyArtistToAll ? artist : (song.artist ?? "")) .font(.system(size: 11)) .foregroundColor(applyArtistToAll ? accentPink : .gray) .lineLimit(1) } Spacer() if song.path != nil { Image(systemName: "checkmark.circle") .font(.system(size: 10)) .foregroundColor(.green.opacity(0.4)) } } } } header: { Text("\(allSongs.count) Tracks") } // Progress if isSaving { Section { VStack(spacing: 8) { ProgressView(value: progress) .tint(accentPink) Text("Updating \(Int(progress * Double(allSongs.count)))/\(allSongs.count)...") .font(.system(size: 12)) .foregroundColor(.gray) } } } if let msg = resultMessage { Section { VStack(alignment: .leading, spacing: 4) { Text(msg) .font(.system(size: 13)) .foregroundColor(failedTracks.isEmpty ? .green : .yellow) ForEach(failedTracks, id: \.self) { t in Text("✗ \(t)") .font(.system(size: 11)) .foregroundColor(.red) } } } } } } // MARK: - Load Albums private func loadAlbums() async { isLoadingAlbums = true var loaded: [AlbumWithSongs] = [] for id in albumIds { do { if let album = try await serverManager.client.getAlbum(id: id) { loaded.append(album) } } catch { DebugLogger.shared.log("Failed to load album \(id): \(error.localizedDescription)", category: "Companion") } } await MainActor.run { albums = loaded isLoadingAlbums = false // Pre-fill from first album if let first = loaded.first { if albumName.isEmpty { albumName = first.name } if albumArtist.isEmpty { albumArtist = first.artist ?? "" } if artist.isEmpty { artist = first.artist ?? "" } if genre.isEmpty { genre = first.genre ?? "" } if year.isEmpty, let y = first.year { year = "\(y)" } } } } // MARK: - Apply Batch private func applyBatch() { let songs = allSongs guard !songs.isEmpty else { return } let paths = songs.compactMap { $0.path } guard !paths.isEmpty else { resultMessage = "No tracks have file paths" return } isSaving = true progress = 0 failedTracks = [] resultMessage = nil Task { do { let request = BatchMetadataEditRequest( relativePaths: paths, title: nil, artist: applyArtistToAll ? artist : nil, album: albumName.isEmpty ? nil : albumName, albumArtist: albumArtist.isEmpty ? nil : albumArtist, genre: genre.isEmpty ? nil : genre, year: Int(year) ) DebugLogger.shared.log( "Multi-album edit: \(paths.count) tracks across \(albums.count) albums, " + "album='\(albumName)', albumArtist='\(albumArtist)', artist=\(applyArtistToAll ? "'\(artist)'" : "nil")", category: "Companion" ) await MainActor.run { progress = 0.3 } let result = try await api.batchEditMetadata(request) await MainActor.run { progress = 1.0 isSaving = false let succeeded = result.succeeded?.count ?? 0 let failed = result.failed ?? [] if failed.isEmpty { resultMessage = "✓ All \(succeeded) tracks updated" } else { resultMessage = "\(succeeded)/\(paths.count) succeeded, \(failed.count) failed" failedTracks = failed.map { "✗ \($0.path): \($0.error)" } } } } catch { await MainActor.run { isSaving = false resultMessage = "Failed: \(error.localizedDescription)" } } } } // MARK: - Helpers private func editField(_ label: String, text: Binding, keyboard: UIKeyboardType = .default) -> some View { HStack { Text(label) .foregroundColor(.gray) .frame(width: 100, alignment: .leading) TextField(label, text: text) .keyboardType(keyboard) .multilineTextAlignment(.trailing) } .font(.system(size: 14)) } }