807 lines
33 KiB
Swift
807 lines
33 KiB
Swift
import SwiftUI
|
|
|
|
// MARK: - MusicBrainz Models
|
|
|
|
struct MBRecording: Identifiable {
|
|
let id: String
|
|
let title: String
|
|
let artist: String
|
|
let album: String
|
|
let albumArtist: String
|
|
let year: String
|
|
let trackNumber: String
|
|
let discNumber: String
|
|
let genre: String
|
|
let country: String
|
|
let label: String
|
|
}
|
|
|
|
// MARK: - MusicBrainz Service
|
|
|
|
struct MusicBrainzService {
|
|
static let baseURL = "https://musicbrainz.org/ws/2"
|
|
static let userAgent = "NavidromePlayer/1.0 (ca.dallasgroot.navidromeplayer.app)"
|
|
|
|
static func search(title: String, artist: String) async -> [MBRecording] {
|
|
let query = "recording:\"\(title)\" AND artist:\"\(artist)\""
|
|
guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
|
let url = URL(string: "\(baseURL)/recording?query=\(encoded)&limit=10&fmt=json") else {
|
|
return []
|
|
}
|
|
var req = URLRequest(url: url)
|
|
req.setValue(userAgent, forHTTPHeaderField: "User-Agent")
|
|
req.timeoutInterval = 10
|
|
|
|
guard let (data, _) = try? await URLSession.shared.data(for: req),
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let recordings = json["recordings"] as? [[String: Any]] else {
|
|
return []
|
|
}
|
|
|
|
var results: [MBRecording] = []
|
|
for rec in recordings {
|
|
let id = rec["id"] as? String ?? UUID().uuidString
|
|
let recTitle = rec["title"] as? String ?? ""
|
|
|
|
// Artist credit
|
|
let credits = rec["artist-credit"] as? [[String: Any]] ?? []
|
|
let artistName = credits.compactMap {
|
|
($0["artist"] as? [String: Any])?["name"] as? String
|
|
}.joined(separator: ", ")
|
|
|
|
// First release
|
|
let releases = rec["releases"] as? [[String: Any]] ?? []
|
|
let release = releases.first
|
|
let albumTitle = release?["title"] as? String ?? ""
|
|
let country = release?["country"] as? String ?? ""
|
|
let releaseDate = release?["date"] as? String ?? ""
|
|
let year = String(releaseDate.prefix(4))
|
|
|
|
// Label
|
|
let labelInfo = (release?["label-info"] as? [[String: Any]])?.first
|
|
let labelName = (labelInfo?["label"] as? [String: Any])?["name"] as? String ?? ""
|
|
|
|
// Track number within release
|
|
let media = release?["media"] as? [[String: Any]]
|
|
let track = (media?.first?["tracks"] as? [[String: Any]])?.first
|
|
let trackPos = (track?["number"] as? String) ?? (track?["position"].map { "\($0)" } ?? "")
|
|
let discPos = media?.first?["position"].map { "\($0)" } ?? ""
|
|
|
|
// Album artist from release artist-credit
|
|
let releaseCredits = release?["artist-credit"] as? [[String: Any]] ?? []
|
|
let releaseArtist = releaseCredits.compactMap {
|
|
($0["artist"] as? [String: Any])?["name"] as? String
|
|
}.joined(separator: ", ")
|
|
|
|
results.append(MBRecording(
|
|
id: id, title: recTitle,
|
|
artist: artistName,
|
|
album: albumTitle,
|
|
albumArtist: releaseArtist.isEmpty ? artistName : releaseArtist,
|
|
year: year,
|
|
trackNumber: trackPos,
|
|
discNumber: discPos == "1" ? "" : discPos,
|
|
genre: "",
|
|
country: country,
|
|
label: labelName
|
|
))
|
|
}
|
|
return results
|
|
}
|
|
|
|
/// Search MusicBrainz by album/release name — used by batch editor.
|
|
static func searchAlbum(album: String, artist: String) async -> [MBRecording] {
|
|
let query = "release:\"\(album)\" AND artist:\"\(artist)\""
|
|
guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
|
let url = URL(string: "\(baseURL)/release?query=\(encoded)&limit=10&fmt=json") else {
|
|
return []
|
|
}
|
|
var req = URLRequest(url: url)
|
|
req.setValue(userAgent, forHTTPHeaderField: "User-Agent")
|
|
req.timeoutInterval = 10
|
|
|
|
guard let (data, _) = try? await URLSession.shared.data(for: req),
|
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let releases = json["releases"] as? [[String: Any]] else {
|
|
return []
|
|
}
|
|
|
|
var results: [MBRecording] = []
|
|
for rel in releases {
|
|
let id = rel["id"] as? String ?? UUID().uuidString
|
|
let title = rel["title"] as? String ?? ""
|
|
let date = rel["date"] as? String ?? ""
|
|
let year = String(date.prefix(4))
|
|
let country = rel["country"] as? String ?? ""
|
|
|
|
let credits = rel["artist-credit"] as? [[String: Any]] ?? []
|
|
let artistName = credits.compactMap {
|
|
($0["artist"] as? [String: Any])?["name"] as? String
|
|
}.joined(separator: ", ")
|
|
|
|
let labelInfo = (rel["label-info"] as? [[String: Any]])?.first
|
|
let labelName = (labelInfo?["label"] as? [String: Any])?["name"] as? String ?? ""
|
|
|
|
// Get track count from media
|
|
let media = rel["media"] as? [[String: Any]] ?? []
|
|
let trackCount = media.compactMap { $0["track-count"] as? Int }.reduce(0, +)
|
|
|
|
results.append(MBRecording(
|
|
id: id, title: title,
|
|
artist: artistName,
|
|
album: title,
|
|
albumArtist: artistName,
|
|
year: year,
|
|
trackNumber: trackCount > 0 ? "\(trackCount) tracks" : "",
|
|
discNumber: media.count > 1 ? "\(media.count) discs" : "",
|
|
genre: "",
|
|
country: country,
|
|
label: labelName
|
|
))
|
|
}
|
|
return results
|
|
}
|
|
}
|
|
|
|
// MARK: - TrackEditorView
|
|
|
|
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
|
|
|
|
// MusicBrainz
|
|
@State private var isSearchingMB = false
|
|
@State private var mbResults: [MBRecording] = []
|
|
@State private var selectedMB: MBRecording?
|
|
@State private var showMBPicker = false
|
|
@State private var mbError: String?
|
|
|
|
// Cover art
|
|
@State private var pendingCoverImage: UIImage? = nil
|
|
@State private var removeCoverArt = false
|
|
@State private var showImagePicker = 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 ||
|
|
pendingCoverImage != nil || removeCoverArt
|
|
}
|
|
|
|
private var coverArtChanged: Bool { pendingCoverImage != nil || removeCoverArt }
|
|
|
|
/// Live preview of where the file will end up after restructure.
|
|
private var pathPreview: String {
|
|
let aa = albumArtist
|
|
let alb = album
|
|
let t = title
|
|
let tn = Int(trackNumber) ?? (song.track ?? 0)
|
|
let dn = Int(discNumber) ?? (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)" } ?? "")
|
|
_albumArtist = State(initialValue: song.albumArtist ?? 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 + cover art
|
|
Section {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
// File details
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
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")
|
|
}
|
|
}
|
|
Spacer()
|
|
// Cover art thumbnail — top right
|
|
coverArtWidget(songId: song.coverArt)
|
|
}
|
|
} header: {
|
|
Text("File Info")
|
|
}
|
|
|
|
// 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.")
|
|
}
|
|
}
|
|
|
|
// Editable fields
|
|
Section {
|
|
mbCheckField("Title", text: $title, isEnabled: $editTitle, mbValue: selectedMB?.title)
|
|
mbCheckField("Artist", text: $artist, isEnabled: $editArtist, mbValue: selectedMB?.artist)
|
|
mbCheckField("Album", text: $album, isEnabled: $editAlbum, mbValue: selectedMB?.album)
|
|
mbCheckField("Album Artist", text: $albumArtist, isEnabled: $editAlbumArtist, mbValue: selectedMB?.albumArtist)
|
|
} header: {
|
|
Text("Basic Info")
|
|
} footer: {
|
|
Text("Check fields you want to change. Only checked fields are updated.")
|
|
}
|
|
|
|
Section {
|
|
mbCheckField("Genre", text: $genre, isEnabled: $editGenre, mbValue: selectedMB?.genre.isEmpty == false ? selectedMB?.genre : nil)
|
|
mbCheckField("Year", text: $year, isEnabled: $editYear, mbValue: selectedMB?.year, keyboard: .numberPad)
|
|
mbCheckField("Track #", text: $trackNumber, isEnabled: $editTrackNumber, mbValue: selectedMB?.trackNumber, keyboard: .numberPad)
|
|
mbCheckField("Disc #", text: $discNumber, isEnabled: $editDiscNumber, mbValue: selectedMB?.discNumber.isEmpty == false ? selectedMB?.discNumber : nil, keyboard: .numberPad)
|
|
} header: {
|
|
Text("Details")
|
|
}
|
|
|
|
// Path preview
|
|
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) {
|
|
HStack(spacing: 14) {
|
|
// MusicBrainz search button
|
|
Button(action: searchMusicBrainz) {
|
|
if isSearchingMB {
|
|
ProgressView().tint(.white).scaleEffect(0.8)
|
|
} else {
|
|
Image(systemName: "magnifyingglass")
|
|
.foregroundColor(selectedMB != nil ? .green : .white)
|
|
}
|
|
}
|
|
.disabled(isSearchingMB)
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.sheet(isPresented: $showMBPicker) {
|
|
MusicBrainzPickerSheet(
|
|
results: mbResults,
|
|
onSelect: { rec in
|
|
selectedMB = rec
|
|
showMBPicker = false
|
|
}
|
|
)
|
|
}
|
|
.sheet(isPresented: $showImagePicker) {
|
|
ImagePickerView { image in
|
|
pendingCoverImage = image
|
|
removeCoverArt = false
|
|
}
|
|
}
|
|
.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: - MusicBrainz Search
|
|
|
|
private func searchMusicBrainz() {
|
|
isSearchingMB = true
|
|
mbError = nil
|
|
Task {
|
|
let results = await MusicBrainzService.search(title: title, artist: artist)
|
|
await MainActor.run {
|
|
isSearchingMB = false
|
|
if results.isEmpty {
|
|
mbError = "No results found on MusicBrainz"
|
|
errorMessage = mbError
|
|
} else {
|
|
mbResults = results
|
|
showMBPicker = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Fetch companion details to populate album artist + disc number
|
|
|
|
private func fetchCompanionDetails() async {
|
|
guard let path = song.path,
|
|
let baseURL = CompanionSettings.shared.baseURL else { return }
|
|
|
|
var components = URLComponents(url: baseURL.appendingPathComponent("library/songs"), resolvingAgainstBaseURL: false)
|
|
components?.queryItems = [URLQueryItem(name: "album", value: song.album ?? "")]
|
|
guard let url = components?.url,
|
|
let (data, _) = try? await URLSession.shared.data(from: url),
|
|
let response = try? JSONDecoder().decode(CompanionLibraryResponse.self, from: data),
|
|
let songs = response.songs else { return }
|
|
|
|
if let match = songs.first(where: { $0.relative_path == path || path.hasSuffix($0.relative_path) }) {
|
|
await MainActor.run {
|
|
if albumArtist.isEmpty || albumArtist == (song.artist ?? "") {
|
|
albumArtist = match.album_artist
|
|
}
|
|
if discNumber.isEmpty, let dn = match.disc_number {
|
|
discNumber = "\(dn)"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Save
|
|
|
|
private func save() {
|
|
guard let path = song.path else { return }
|
|
isSaving = true
|
|
errorMessage = nil
|
|
|
|
Task {
|
|
do {
|
|
// Handle cover art first
|
|
if let image = pendingCoverImage, let companionId = companionSongId() {
|
|
try await api.uploadCoverArt(songId: companionId, image: image)
|
|
} else if removeCoverArt, let companionId = companionSongId() {
|
|
try await api.deleteCoverArt(songId: companionId)
|
|
}
|
|
|
|
// Only send metadata edit if any tag fields are checked
|
|
let hasTagChanges = editTitle || editArtist || editAlbum || editAlbumArtist ||
|
|
editGenre || editYear || editTrackNumber || editDiscNumber
|
|
if hasTagChanges {
|
|
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)
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Extract the Companion song ID from coverArt field ("companion:{id}")
|
|
private func companionSongId() -> String? {
|
|
guard let coverArt = song.coverArt, coverArt.hasPrefix("companion:") else { return nil }
|
|
return String(coverArt.dropFirst("companion:".count))
|
|
}
|
|
|
|
// 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: - Cover Art Widget
|
|
|
|
private func coverArtWidget(songId: String?) -> some View {
|
|
CoverArtEditorWidget(
|
|
existingCoverArtId: songId,
|
|
pendingImage: $pendingCoverImage,
|
|
removeArt: $removeCoverArt,
|
|
showPicker: $showImagePicker,
|
|
size: 72
|
|
)
|
|
}
|
|
|
|
// MARK: - Checkmark Field with optional MusicBrainz suggestion
|
|
|
|
private func mbCheckField(
|
|
_ label: String,
|
|
text: Binding<String>,
|
|
isEnabled: Binding<Bool>,
|
|
mbValue: String?,
|
|
keyboard: UIKeyboardType = .default
|
|
) -> some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
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))
|
|
|
|
// MusicBrainz suggestion pill — only show if different from current value
|
|
if let mb = mbValue, !mb.isEmpty, mb != text.wrappedValue {
|
|
Button(action: {
|
|
text.wrappedValue = mb
|
|
isEnabled.wrappedValue = true
|
|
}) {
|
|
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, 30)
|
|
}
|
|
}
|
|
.padding(.vertical, mbValue != nil && mbValue != text.wrappedValue ? 2 : 0)
|
|
}
|
|
}
|
|
|
|
// MARK: - MusicBrainz Picker Sheet
|
|
|
|
struct MusicBrainzPickerSheet: View {
|
|
let results: [MBRecording]
|
|
let onSelect: (MBRecording) -> Void
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
List(results) { rec in
|
|
Button(action: { onSelect(rec) }) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(rec.title)
|
|
.font(.system(size: 14, weight: .semibold))
|
|
.foregroundColor(.white)
|
|
Text(rec.artist)
|
|
.font(.system(size: 13))
|
|
.foregroundColor(.gray)
|
|
HStack(spacing: 8) {
|
|
if !rec.album.isEmpty {
|
|
Text(rec.album)
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.white.opacity(0.6))
|
|
}
|
|
if !rec.year.isEmpty {
|
|
Text(rec.year)
|
|
.font(.system(size: 12))
|
|
.foregroundColor(.white.opacity(0.5))
|
|
}
|
|
if !rec.country.isEmpty {
|
|
Text(rec.country)
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.white.opacity(0.4))
|
|
}
|
|
if !rec.trackNumber.isEmpty {
|
|
Text("Track \(rec.trackNumber)")
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.white.opacity(0.4))
|
|
}
|
|
}
|
|
if !rec.label.isEmpty {
|
|
Text(rec.label)
|
|
.font(.system(size: 11))
|
|
.foregroundColor(.gray.opacity(0.7))
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.navigationTitle("MusicBrainz Results")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button("Cancel") { dismiss() }
|
|
.foregroundColor(.gray)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Cover Art Widget (shared by TrackEditorView and BatchAlbumEditorSheet)
|
|
|
|
struct CoverArtEditorWidget: View {
|
|
let existingCoverArtId: String?
|
|
@Binding var pendingImage: UIImage?
|
|
@Binding var removeArt: Bool
|
|
@Binding var showPicker: Bool
|
|
let size: CGFloat
|
|
|
|
private var isChanged: Bool { pendingImage != nil || removeArt }
|
|
|
|
var body: some View {
|
|
ZStack(alignment: .bottomTrailing) {
|
|
Group {
|
|
if let img = pendingImage {
|
|
Image(uiImage: img)
|
|
.resizable().scaledToFill()
|
|
} else if removeArt {
|
|
ZStack {
|
|
Color.gray.opacity(0.2)
|
|
Image(systemName: "photo.slash")
|
|
.font(.system(size: size * 0.35))
|
|
.foregroundColor(.gray)
|
|
}
|
|
} else if let id = existingCoverArtId,
|
|
id.hasPrefix("companion:"),
|
|
let url = CompanionSettings.shared.baseURL?
|
|
.appendingPathComponent("library/cover-art/\(id.dropFirst("companion:".count))") {
|
|
AsyncImage(url: url) { phase in
|
|
switch phase {
|
|
case .success(let img): img.resizable().scaledToFill()
|
|
default:
|
|
ZStack {
|
|
Color.gray.opacity(0.15)
|
|
Image(systemName: "music.note")
|
|
.font(.system(size: size * 0.35))
|
|
.foregroundColor(.gray)
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
ZStack {
|
|
Color.gray.opacity(0.15)
|
|
Image(systemName: "photo.badge.plus")
|
|
.font(.system(size: size * 0.35))
|
|
.foregroundColor(.gray.opacity(0.7))
|
|
}
|
|
}
|
|
}
|
|
.frame(width: size, height: size)
|
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.stroke(isChanged ? Color.red.opacity(0.9) : Color.clear, lineWidth: 2)
|
|
.shadow(color: isChanged ? .red.opacity(0.6) : .clear, radius: 4)
|
|
)
|
|
|
|
if pendingImage != nil || (existingCoverArtId != nil && !removeArt) {
|
|
Menu {
|
|
Button { showPicker = true } label: {
|
|
Label("Replace", systemImage: "photo")
|
|
}
|
|
Button(role: .destructive) {
|
|
pendingImage = nil
|
|
removeArt = true
|
|
} label: {
|
|
Label("Remove", systemImage: "trash")
|
|
}
|
|
} label: {
|
|
Image(systemName: "ellipsis.circle.fill")
|
|
.font(.system(size: 18))
|
|
.foregroundColor(.white)
|
|
.shadow(radius: 2)
|
|
}
|
|
.offset(x: 4, y: 4)
|
|
}
|
|
}
|
|
.frame(width: size, height: size)
|
|
.onTapGesture {
|
|
if pendingImage == nil && existingCoverArtId == nil {
|
|
showPicker = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Image Picker (UIKit wrapper)
|
|
|
|
struct ImagePickerView: UIViewControllerRepresentable {
|
|
let onPick: (UIImage) -> Void
|
|
|
|
func makeCoordinator() -> Coordinator { Coordinator(onPick: onPick) }
|
|
|
|
func makeUIViewController(context: Context) -> UIImagePickerController {
|
|
let picker = UIImagePickerController()
|
|
picker.sourceType = .photoLibrary
|
|
picker.allowsEditing = true
|
|
picker.delegate = context.coordinator
|
|
return picker
|
|
}
|
|
|
|
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
|
|
|
|
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
|
|
let onPick: (UIImage) -> Void
|
|
init(onPick: @escaping (UIImage) -> Void) { self.onPick = onPick }
|
|
|
|
func imagePickerController(_ picker: UIImagePickerController,
|
|
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
|
|
let img = (info[.editedImage] ?? info[.originalImage]) as? UIImage
|
|
picker.dismiss(animated: true)
|
|
if let img { onPick(img) }
|
|
}
|
|
|
|
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
|
|
picker.dismiss(animated: true)
|
|
}
|
|
}
|
|
}
|