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
336 lines
12 KiB
Swift
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))
|
|
}
|
|
}
|