NavidromeApp/iOS/Views/Companion/MultiAlbumEditorSheet.swift
Dallas Groot f3b9483b23 overhaul
AUDIT-036 — Slider/button fixes (direct Liquid Glass cause)
scheduleFlush() now runs Task { @MainActor } instead of bare Task. The
pendingSaves dictionary is now only ever read/written on the main
thread. Before this fix, a UserDefaults write could race with a slider
didSet, causing values to snap back or write the wrong value — which
is exactly why buttons were switching state unexpectedly.
AUDIT-034 — 60fps idle Canvas (direct Liquid Glass cause)
TimelineView now uses isRenderingActive ? settings.effectiveFPS : 2.0.
When paused or not visible, the Canvas drops from 60fps to 2fps. This
stops the continuous GPU wakeups that were fighting Liquid Glass
gesture tracking, which is why sliders needed multiple attempts.
AUDIT-001 — FFT real-time heap allocation
processFFT no longer allocates any heap memory. The Hann window is
computed once in init(). All four scratch buffers (fftWindow,
fftWindowed, fftRealp/fftImagp, fftMagnitudes) are pre-allocated and
reused every render callback — zero allocations on the real-time audio
thread.
AUDIT-002 — WatchOfflineStore data race
taskToSongId and pendingSongs now protected by a dedicated serial
storeQueue. URLSession delegate reads and main thread writes are
serialised.
AUDIT-019 — URLSession per AsyncCoverArt render
CompanionAPIService() no longer instantiated per render. Companion
cover art URLs now built directly from
CompanionSettings.shared.baseURL — no URLSession created.
AUDIT-020 — Synchronous disk read on main thread
CachedImageLoader now uses memoryOnlyImage (sync, no I/O) for the
first check, then cachedImageAsync (disk read on ioQueue) for the
second. Main thread never blocks on disk I/O.
AUDIT-033 — Lost star/unstar actions offline
Star/unstar now routes through OptimisticActionQueue — actions survive
Tailscale reconnection and are retried automatically.
AUDIT-035 — OptimisticActionQueue flush race
flush() Task is now @MainActor — pendingActions only ever touched on
main thread, no more race between rapid taps and in-flight flushes.
AUDIT-038 — O(n²) deduplication
deduplicateAlbums now O(n) using a frequency dictionary. For 843
albums: ~7.1M string comparisons/second during playback → ~1,700.
AUDIT-026, AUDIT-015 — Duplicate setResourceValue removed, cacheSize
now uses totalSize directly
2026-04-11 11:17:40 -07:00

487 lines
20 KiB
Swift

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<String> = []
// 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<String>,
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)
}
}