NavidromeApp/iOS/Views/Companion/MultiAlbumEditorSheet.swift
Dallas Groot d8041c0019 NavidromePlayer: iOS + watchOS Navidrome/Subsonic music player
Features:
- Dual-AVPlayer Smart DJ crossfade with LUFS normalization
- Mitsuha-style FFT visualizer (real-time + offline pre-computed)
- Companion API integration (Smart DJ, tag editing, vis frames)
- Offline-first SyncEngine with delta sync and album detail pre-caching
- Audio pre-fetcher for gapless queue playback
- Optimistic action queue (star/unstar with background retry)
- ShazamKit recognition with MusicKit preview playback
- Radio streaming with HLS/PLS/M3U support and buffer seek
- Watch app with Crown Sequencer and Ultra speaker support
- Batch metadata editing with album_artist fix for split albums
- Cache-first UI pattern across all views
- NWPathMonitor offline detection with reactive song greying
2026-03-28 20:49:47 +00:00

336 lines
12 KiB
Swift

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