import SwiftUI /// Batch-edit tags across one or more albums selected from the library. /// Mirrors BatchAlbumEditorSheet but handles multiple albums. 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 // Editable fields @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 // Song removal @State private var excludedSongIds: Set = [] // MusicBrainz @State private var isSearchingMB = false @State private var mbResults: [MBRecording] = [] @State private var selectedMB: MBRecording? @State private var showMBPicker = false // Cover art @State private var pendingCoverImage: UIImage? = nil @State private var removeCoverArt = false @State private var showImagePicker = false // Save state @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 ?? [] } } private var includedSongs: [Song] { allSongs.filter { !excludedSongIds.contains($0.id) } } private var coverArtSongId: String? { includedSongs.first(where: { $0.coverArt?.hasPrefix("companion:") == true })?.coverArt } private var coverArtCompanionId: String? { guard let id = coverArtSongId, id.hasPrefix("companion:") else { return nil } return String(id.dropFirst("companion:".count)) } 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 { HStack(spacing: 14) { Button(action: searchMusicBrainz) { if isSearchingMB { ProgressView().tint(.white).scaleEffect(0.8) } else { Image(systemName: "magnifyingglass") .foregroundColor(selectedMB != nil ? .green : .white) } } .disabled(isSearchingMB) Button("Apply", action: applyBatch) .fontWeight(.semibold) .foregroundColor(accentPink) .disabled(isSaving || (albumName.isEmpty && pendingCoverImage == nil && !removeCoverArt)) } } } } .sheet(isPresented: $showMBPicker) { MusicBrainzPickerSheet(results: mbResults) { rec in selectedMB = rec showMBPicker = false } } .sheet(isPresented: $showImagePicker) { ImagePickerView { image in pendingCoverImage = image removeCoverArt = false } } .task { await loadAlbums() } } } // MARK: - Loading / No API views 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 { // Cover art + scope header Section { HStack(alignment: .top, spacing: 14) { CoverArtEditorWidget( existingCoverArtId: coverArtSongId, pendingImage: $pendingCoverImage, removeArt: $removeCoverArt, showPicker: $showImagePicker, size: 72 ) VStack(alignment: .leading, spacing: 4) { Text(albumIds.count == 1 ? albumName : "\(albumIds.count) Albums") .font(.system(size: 14, weight: .semibold)) .foregroundColor(.white) .lineLimit(2) Text("\(includedSongs.count) of \(allSongs.count) tracks selected") .font(.system(size: 12)) .foregroundColor(.gray) if pendingCoverImage != nil { Text("Cover art will be replaced") .font(.system(size: 11)) .foregroundColor(.red.opacity(0.8)) } else if removeCoverArt { Text("Cover art will be removed") .font(.system(size: 11)) .foregroundColor(.red.opacity(0.8)) } } Spacer() } .padding(.vertical, 4) } header: { Text("Selected Albums") } footer: { Text("Changes apply to selected tracks across all albums. Navidrome rescans automatically.") } // MusicBrainz suggestion banner if let mb = selectedMB { Section { VStack(alignment: .leading, spacing: 4) { HStack { Image(systemName: "checkmark.seal.fill") .foregroundColor(.green) .font(.system(size: 13)) Text("MusicBrainz: \(mb.album)") .font(.system(size: 13, weight: .medium)) .foregroundColor(.white) Spacer() Button("Change") { showMBPicker = true } .font(.system(size: 12)) .foregroundColor(accentPink) } if !mb.label.isEmpty { Text("\(mb.country) · \(mb.label)") .font(.system(size: 11)) .foregroundColor(.gray) } } .padding(.vertical, 2) } header: { Text("MusicBrainz Match") } footer: { Text("Tap ↓ next to any field to apply the suggestion.") } } // Album tag fields Section { mbEditField("Album", text: $albumName, mbValue: selectedMB?.album) mbEditField("Album Artist", text: $albumArtist, mbValue: selectedMB?.albumArtist) mbEditField("Genre", text: $genre, mbValue: selectedMB?.genre.isEmpty == false ? selectedMB?.genre : nil) mbEditField("Year", text: $year, mbValue: selectedMB?.year, keyboard: .numberPad) } header: { Text("Album Tags") } // Artist override Section { Toggle("Set same artist on all tracks", isOn: $applyArtistToAll) .tint(accentPink) if applyArtistToAll { mbEditField("Artist", text: $artist, mbValue: selectedMB?.artist) } } header: { Text("Artist Override") } footer: { if applyArtistToAll { Text("Every selected track gets the same artist.") } else { Text("Each track keeps its current artist.") } } // Track list with swipe-to-remove Section { ForEach(allSongs, id: \.id) { song in HStack(spacing: 8) { Text("\(song.track ?? 0)") .font(.system(size: 11, design: .monospaced)) .foregroundColor(excludedSongIds.contains(song.id) ? .gray.opacity(0.4) : .gray) .frame(width: 20) VStack(alignment: .leading, spacing: 1) { Text(song.title) .font(.system(size: 13)) .foregroundColor(excludedSongIds.contains(song.id) ? .gray.opacity(0.4) : .white) .lineLimit(1) Text(applyArtistToAll ? artist : (song.artist ?? "")) .font(.system(size: 11)) .foregroundColor(applyArtistToAll ? accentPink.opacity(excludedSongIds.contains(song.id) ? 0.3 : 1) : .gray.opacity(excludedSongIds.contains(song.id) ? 0.4 : 1)) .lineLimit(1) } Spacer() if excludedSongIds.contains(song.id) { Text("Excluded") .font(.system(size: 10)) .foregroundColor(.gray.opacity(0.5)) } else if song.path != nil { Image(systemName: "checkmark.circle") .font(.system(size: 10)) .foregroundColor(.green.opacity(0.4)) } } .swipeActions(edge: .trailing, allowsFullSwipe: true) { Button { withAnimation { if excludedSongIds.contains(song.id) { excludedSongIds.remove(song.id) } else { excludedSongIds.insert(song.id) } } } label: { Label( excludedSongIds.contains(song.id) ? "Include" : "Exclude", systemImage: excludedSongIds.contains(song.id) ? "plus.circle" : "minus.circle" ) } .tint(excludedSongIds.contains(song.id) ? accentPink : .red) } } } header: { HStack { Text("Tracks (\(includedSongs.count) included)") Spacer() if !excludedSongIds.isEmpty { Button("Reset") { withAnimation { excludedSongIds.removeAll() } } .font(.system(size: 12)) .foregroundColor(accentPink) } } } footer: { Text("Swipe left to exclude a track from this edit.") } // Progress / Results if isSaving { Section { VStack(spacing: 8) { ProgressView(value: progress).tint(accentPink) Text("Updating \(Int(progress * Double(includedSongs.count)))/\(includedSongs.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 if let first = loaded.first { if albumName.isEmpty { albumName = first.name } if albumArtist.isEmpty { albumArtist = first.albumArtist ?? first.artist ?? "" } if artist.isEmpty { artist = first.albumArtist ?? first.artist ?? "" } if genre.isEmpty { genre = first.genre ?? "" } if year.isEmpty, let y = first.year { year = "\(y)" } } } } // MARK: - MusicBrainz private func searchMusicBrainz() { isSearchingMB = true Task { let results = await MusicBrainzService.searchAlbum( album: albumName, artist: albumArtist ) await MainActor.run { isSearchingMB = false if !results.isEmpty { mbResults = results showMBPicker = true } } } } // MARK: - Apply Batch private func applyBatch() { guard !includedSongs.isEmpty || pendingCoverImage != nil || removeCoverArt else { return } isSaving = true progress = 0 failedTracks = [] resultMessage = nil Task { do { // Cover art — use first included song's companion ID if let image = pendingCoverImage, let cid = coverArtCompanionId { try await api.uploadCoverArt(songId: cid, image: image) } else if removeCoverArt, let cid = coverArtCompanionId { try await api.deleteCoverArt(songId: cid) } await MainActor.run { progress = 0.2 } let paths = includedSongs.compactMap { $0.path } if !paths.isEmpty { 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", category: "Companion" ) await MainActor.run { progress = 0.4 } let result = try await api.batchEditMetadata(request) for song in includedSongs { WatchConnectivityManager.shared.notifyTagsUpdated( songId: song.id, title: nil, artist: applyArtistToAll ? (artist.isEmpty ? nil : artist) : nil, album: albumName.isEmpty ? nil : albumName, albumArtist: albumArtist.isEmpty ? nil : albumArtist ) } 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)" } } } } else { await MainActor.run { progress = 1.0 isSaving = false resultMessage = "✓ Cover art updated" } } } catch { await MainActor.run { isSaving = false resultMessage = "Failed: \(error.localizedDescription)" } } } } // MARK: - Field with optional MB suggestion pill private func mbEditField( _ label: String, text: Binding, mbValue: String?, keyboard: UIKeyboardType = .default ) -> some View { VStack(alignment: .leading, spacing: 4) { HStack { Text(label) .foregroundColor(.gray) .frame(width: 100, alignment: .leading) TextField(label, text: text) .keyboardType(keyboard) .multilineTextAlignment(.trailing) .keyboardDoneButton() } .font(.system(size: 14)) if let mb = mbValue, !mb.isEmpty, mb != text.wrappedValue { Button { text.wrappedValue = mb } label: { HStack(spacing: 4) { Image(systemName: "arrow.down.circle.fill").font(.system(size: 10)) Text(mb).font(.system(size: 11)).lineLimit(1) } .foregroundColor(.white) .padding(.horizontal, 8) .padding(.vertical, 3) .background(Color.blue.opacity(0.35)) .cornerRadius(8) } .buttonStyle(.plain) .padding(.leading, 4) } } .padding(.vertical, mbValue != nil && mbValue != text.wrappedValue ? 2 : 0) } }