Active tab — all unignored issues, swipe left → “Ignore” (grey) • Ignored tab — all ignored issues shown dimmed, swipe left → “Restore” (pink) to bring them back • Fix buttons hidden on ignored issues • Ignored IDs persisted in @AppStorage so they survive app restarts • Tab labels show live counts: Active (1) | Ignored (31)
315 lines
13 KiB
Swift
315 lines
13 KiB
Swift
import SwiftUI
|
|
|
|
struct TrackEditorView: View {
|
|
let song: Song
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
// Field values
|
|
@State private var title: String
|
|
@State private var artist: String
|
|
@State private var album: String
|
|
@State private var albumArtist: String
|
|
@State private var genre: String
|
|
@State private var year: String
|
|
@State private var trackNumber: String
|
|
@State private var discNumber: String
|
|
|
|
// Checkmarks — only checked fields get sent to the API
|
|
@State private var editTitle = false
|
|
@State private var editArtist = false
|
|
@State private var editAlbum = false
|
|
@State private var editAlbumArtist = false
|
|
@State private var editGenre = false
|
|
@State private var editYear = false
|
|
@State private var editTrackNumber = false
|
|
@State private var editDiscNumber = false
|
|
|
|
@State private var isSaving = false
|
|
@State private var errorMessage: String?
|
|
@State private var showSuccess = false
|
|
|
|
@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 hasSelection: Bool {
|
|
editTitle || editArtist || editAlbum || editAlbumArtist ||
|
|
editGenre || editYear || editTrackNumber || editDiscNumber
|
|
}
|
|
|
|
/// Live preview of where the file will end up after restructure.
|
|
private var pathPreview: String {
|
|
let aa = editAlbumArtist ? albumArtist : albumArtist
|
|
let alb = editAlbum ? album : album
|
|
let t = editTitle ? title : title
|
|
let tn = editTrackNumber ? (Int(trackNumber) ?? 0) : (song.track ?? 0)
|
|
let dn = editDiscNumber ? (Int(discNumber) ?? 0) : (song.discNumber ?? 0)
|
|
let ext = (song.suffix ?? "").isEmpty ? "flac" : (song.suffix ?? "flac")
|
|
|
|
let prefix: String
|
|
if dn > 1 {
|
|
prefix = String(format: "%02d-%02d", dn, tn)
|
|
} else if tn > 0 {
|
|
prefix = String(format: "%02d", tn)
|
|
} else {
|
|
prefix = ""
|
|
}
|
|
|
|
let sanitized = { (s: String) -> String in
|
|
s.replacingOccurrences(of: "[/\\\\:*?\"<>|]", with: "",
|
|
options: .regularExpression).trimmingCharacters(in: .whitespaces)
|
|
}
|
|
|
|
let folder = "\(sanitized(aa.isEmpty ? "Unknown Artist" : aa))/\(sanitized(alb.isEmpty ? "Unknown Album" : alb))"
|
|
let file = prefix.isEmpty ? "\(sanitized(t)).\(ext)" : "\(prefix) \(sanitized(t)).\(ext)"
|
|
return "\(folder)/\(file)"
|
|
}
|
|
|
|
init(song: Song) {
|
|
self.song = song
|
|
_title = State(initialValue: song.title)
|
|
_artist = State(initialValue: song.artist ?? "")
|
|
_album = State(initialValue: song.album ?? "")
|
|
_genre = State(initialValue: song.genre ?? "")
|
|
_year = State(initialValue: song.year.map { "\($0)" } ?? "")
|
|
_trackNumber = State(initialValue: song.track.map { "\($0)" } ?? "")
|
|
_discNumber = State(initialValue: song.discNumber.map { "\($0)" } ?? "")
|
|
|
|
// Pre-populate album artist from companion cover art ID if available
|
|
// Falls back to artist; will be overwritten by fetchCompanionDetails on appear
|
|
_albumArtist = State(initialValue: song.artist ?? "")
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List {
|
|
if !settings.isEnabled {
|
|
Section {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "exclamationmark.triangle")
|
|
.font(.system(size: 28))
|
|
.foregroundColor(.yellow)
|
|
Text("Companion API Required")
|
|
.font(.system(size: 15, weight: .medium))
|
|
Text("Configure the Companion API in Settings to edit track metadata.")
|
|
.font(.system(size: 13))
|
|
.foregroundColor(.gray)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 12)
|
|
}
|
|
} else {
|
|
// File info (read-only)
|
|
Section {
|
|
infoRow("File", song.path ?? song.id)
|
|
if let suffix = song.suffix {
|
|
infoRow("Format", suffix.uppercased())
|
|
}
|
|
if let bitRate = song.bitRate {
|
|
infoRow("Bit Rate", "\(bitRate) kbps")
|
|
}
|
|
} header: {
|
|
Text("File Info")
|
|
}
|
|
|
|
// Editable fields
|
|
Section {
|
|
checkField("Title", text: $title, isEnabled: $editTitle)
|
|
checkField("Artist", text: $artist, isEnabled: $editArtist)
|
|
checkField("Album", text: $album, isEnabled: $editAlbum)
|
|
checkField("Album Artist", text: $albumArtist, isEnabled: $editAlbumArtist)
|
|
} header: {
|
|
Text("Basic Info")
|
|
} footer: {
|
|
Text("Check fields you want to change. Only checked fields are updated.")
|
|
}
|
|
|
|
Section {
|
|
checkField("Genre", text: $genre, isEnabled: $editGenre)
|
|
checkField("Year", text: $year, isEnabled: $editYear, keyboard: .numberPad)
|
|
checkField("Track #", text: $trackNumber, isEnabled: $editTrackNumber, keyboard: .numberPad)
|
|
checkField("Disc #", text: $discNumber, isEnabled: $editDiscNumber, keyboard: .numberPad)
|
|
} header: {
|
|
Text("Details")
|
|
}
|
|
|
|
// Path preview — always visible
|
|
Section {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("New path")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.gray)
|
|
Text(pathPreview)
|
|
.font(.system(size: 12, design: .monospaced))
|
|
.foregroundColor(.white.opacity(0.7))
|
|
.lineLimit(3)
|
|
}
|
|
.padding(.vertical, 4)
|
|
} header: {
|
|
Text("After Save")
|
|
} footer: {
|
|
Text("The file will be moved to this path on the server after tags are saved.")
|
|
}
|
|
|
|
if let error = errorMessage {
|
|
Section {
|
|
Text(error)
|
|
.font(.system(size: 13))
|
|
.foregroundColor(.red)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("Edit Tags")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
Button("Cancel") { dismiss() }
|
|
.foregroundColor(.gray)
|
|
}
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
if settings.isEnabled {
|
|
Button(action: save) {
|
|
if isSaving {
|
|
ProgressView().tint(accentPink)
|
|
} else {
|
|
Text("Save")
|
|
.fontWeight(.semibold)
|
|
.foregroundColor(hasSelection ? accentPink : .gray)
|
|
}
|
|
}
|
|
.disabled(isSaving || !hasSelection || song.path == nil)
|
|
}
|
|
}
|
|
}
|
|
.overlay {
|
|
if showSuccess {
|
|
VStack(spacing: 12) {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.font(.system(size: 44))
|
|
.foregroundColor(.green)
|
|
Text("Tags Updated")
|
|
.font(.system(size: 16, weight: .medium))
|
|
.foregroundColor(.white)
|
|
Text("File moved to new path")
|
|
.font(.system(size: 13))
|
|
.foregroundColor(.gray)
|
|
}
|
|
.padding(30)
|
|
.background(.ultraThinMaterial)
|
|
.cornerRadius(16)
|
|
.transition(.scale.combined(with: .opacity))
|
|
}
|
|
}
|
|
.task {
|
|
await fetchCompanionDetails()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Fetch companion details to populate album artist + disc number
|
|
|
|
private func fetchCompanionDetails() async {
|
|
guard let coverArt = song.coverArt, coverArt.hasPrefix("companion:") else { return }
|
|
let companionId = String(coverArt.dropFirst("companion:".count))
|
|
guard let baseURL = CompanionSettings.shared.baseURL else { return }
|
|
let url = baseURL.appendingPathComponent("library/song/\(companionId)")
|
|
guard let (data, _) = try? await URLSession.shared.data(from: url),
|
|
let json = try? JSONDecoder().decode(CompanionSong.self, from: data) else { return }
|
|
await MainActor.run {
|
|
if albumArtist.isEmpty || albumArtist == artist {
|
|
albumArtist = json.album_artist
|
|
}
|
|
if discNumber.isEmpty, let dn = json.disc_number {
|
|
discNumber = "\(dn)"
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Save
|
|
|
|
private func save() {
|
|
guard let path = song.path else { return }
|
|
isSaving = true
|
|
errorMessage = nil
|
|
|
|
Task {
|
|
do {
|
|
let request = MetadataEditRequest(
|
|
relativePath: path,
|
|
title: editTitle ? (title.isEmpty ? nil : title) : nil,
|
|
artist: editArtist ? (artist.isEmpty ? nil : artist) : nil,
|
|
album: editAlbum ? (album.isEmpty ? nil : album) : nil,
|
|
albumArtist: editAlbumArtist ? (albumArtist.isEmpty ? nil : albumArtist) : nil,
|
|
genre: editGenre ? (genre.isEmpty ? nil : genre) : nil,
|
|
year: editYear ? Int(year) : nil,
|
|
trackNumber: editTrackNumber ? Int(trackNumber) : nil
|
|
)
|
|
|
|
try await api.editMetadata(request)
|
|
|
|
// Notify the watch so it can update its offline store metadata
|
|
WatchConnectivityManager.shared.notifyTagsUpdated(
|
|
songId: song.id,
|
|
title: request.title,
|
|
artist: request.artist,
|
|
album: request.album,
|
|
albumArtist: request.albumArtist
|
|
)
|
|
|
|
await MainActor.run {
|
|
isSaving = false
|
|
withAnimation { showSuccess = true }
|
|
DebugLogger.shared.log("Tags updated: \(title) — \(artist)", category: "Companion")
|
|
}
|
|
|
|
try? await Task.sleep(for: .seconds(1.2))
|
|
await MainActor.run { dismiss() }
|
|
|
|
} catch {
|
|
await MainActor.run {
|
|
isSaving = false
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Info Row (read-only)
|
|
|
|
private func infoRow(_ label: String, _ value: String) -> some View {
|
|
HStack {
|
|
Text(label).foregroundColor(.gray)
|
|
Spacer()
|
|
Text(value).foregroundColor(.white.opacity(0.7)).lineLimit(1)
|
|
}
|
|
.font(.system(size: 14))
|
|
}
|
|
|
|
// MARK: - Checkmark Field
|
|
|
|
private func checkField(_ label: String, text: Binding<String>, isEnabled: Binding<Bool>, keyboard: UIKeyboardType = .default) -> some View {
|
|
HStack(spacing: 10) {
|
|
Button(action: { isEnabled.wrappedValue.toggle() }) {
|
|
Image(systemName: isEnabled.wrappedValue ? "checkmark.circle.fill" : "circle")
|
|
.font(.system(size: 20))
|
|
.foregroundColor(isEnabled.wrappedValue ? accentPink : .gray.opacity(0.4))
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
Text(label)
|
|
.foregroundColor(isEnabled.wrappedValue ? .white : .gray)
|
|
.frame(width: 85, alignment: .leading)
|
|
|
|
TextField(label, text: text)
|
|
.keyboardType(keyboard)
|
|
.multilineTextAlignment(.trailing)
|
|
.foregroundColor(isEnabled.wrappedValue ? .white : .gray.opacity(0.5))
|
|
.disabled(!isEnabled.wrappedValue)
|
|
.keyboardDoneButton()
|
|
}
|
|
.font(.system(size: 13))
|
|
}
|
|
}
|