1311 lines
64 KiB
Swift
1311 lines
64 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
struct LibraryView: View {
|
|
@EnvironmentObject private var app: AppModel
|
|
@AppStorage("library.folderSidebarWidth") private var folderSidebarWidth = 285.0
|
|
@StateObject private var player = AudioPlayerModel()
|
|
@State private var showImporter = false
|
|
@State private var showYouTubeImport = false
|
|
@State private var showCoverPicker = false
|
|
@State private var showMetadata = false
|
|
@State private var showTagging = false
|
|
@State private var showAcoustIDTagging = false
|
|
@State private var showCardWriter = false
|
|
@State private var showCleanConfirm = false
|
|
@State private var showCreateProfile = false
|
|
@State private var showSyncProfile = false
|
|
@State private var showProfileManager = false
|
|
@State private var profileToRename: LibraryProfile?
|
|
@State private var showDeleteProfileConfirm = false
|
|
@State private var editingTrack: MusicTrack?
|
|
@State private var editingFolderNumber: Int?
|
|
@State private var folderToRename: MusicFolder?
|
|
@State private var taggingTracks: [MusicTrack] = []
|
|
@State private var isEditingFolderName = false
|
|
@State private var editedFolderName = ""
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text("Musikbibliothek").font(.largeTitle.bold())
|
|
Text(app.library.activeProfile.map { "Lokales Profil · \($0.name)" } ?? app.library.rootURL?.path ?? "Wähle eine SD-Karte oder erstelle ein lokales Profil")
|
|
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
|
}
|
|
Spacer()
|
|
Menu {
|
|
if !app.library.profiles.isEmpty {
|
|
Section("Lokale Profile") {
|
|
ForEach(app.library.profiles) { profile in
|
|
Button {
|
|
app.library.openProfile(profile.id)
|
|
app.status = "Lokales Profil \(profile.name) geöffnet"
|
|
} label: {
|
|
if app.library.activeProfileID == profile.id {
|
|
Label(profile.name, systemImage: "checkmark")
|
|
} else { Text(profile.name) }
|
|
}
|
|
}
|
|
}
|
|
Divider()
|
|
}
|
|
Button("Lokales Profil erstellen…", systemImage: "plus") { showCreateProfile = true }
|
|
Button("Profile verwalten…", systemImage: "slider.horizontal.3") { showProfileManager = true }
|
|
if let profile = app.library.activeProfile {
|
|
Button("Profil umbenennen…", systemImage: "pencil") { profileToRename = profile }
|
|
Button("Profil löschen…", systemImage: "trash", role: .destructive) { showDeleteProfileConfirm = true }
|
|
}
|
|
} label: {
|
|
Label(app.library.activeProfile?.name ?? "Profile", systemImage: "externaldrive.badge.person.crop")
|
|
}
|
|
.menuStyle(.borderedButton)
|
|
if app.library.activeProfile != nil {
|
|
Button("Mit SD-Karte synchronisieren…", systemImage: "arrow.triangle.2.circlepath") { showSyncProfile = true }
|
|
.disabled(app.library.isLoading || app.library.isSyncing)
|
|
}
|
|
Button("SD-Karte direkt öffnen…", systemImage: "externaldrive") { chooseCard() }
|
|
.help("Eine eingelegte SD-Karte direkt bearbeiten")
|
|
}.padding()
|
|
Divider()
|
|
if app.library.rootURL == nil {
|
|
mountedVolumesView
|
|
} else {
|
|
PersistentFolderSplitView(sidebarWidth: $folderSidebarWidth) {
|
|
folderSidebar.environmentObject(app)
|
|
} detail: {
|
|
trackPanel.environmentObject(app)
|
|
}
|
|
}
|
|
Divider()
|
|
PlayerBar(player: player, tracks: app.library.currentTracks).padding(.horizontal).padding(.vertical, 12)
|
|
}
|
|
.onChange(of: app.library.selectedFolder) { newValue in
|
|
if newValue != nil { app.library.selectedSystemFolderName = nil }
|
|
isEditingFolderName = false
|
|
editedFolderName = app.library.currentFolder?.customName ?? app.library.currentFolder?.title ?? ""
|
|
}
|
|
.fileImporter(isPresented: $showImporter, allowedContentTypes: [.audio], allowsMultipleSelection: true) { result in
|
|
Task {
|
|
do { try await app.library.importFiles(try result.get(), to: app.library.selectedFolder ?? 0); app.status = "Tracks importiert" }
|
|
catch { app.report(error) }
|
|
}
|
|
}
|
|
.fileImporter(isPresented: $showCoverPicker, allowedContentTypes: [.image], allowsMultipleSelection: false) { result in
|
|
do {
|
|
guard let url = try result.get().first else { return }
|
|
if let editingFolderNumber, let folder = app.library.folders.first(where: { $0.number == editingFolderNumber }) {
|
|
for track in folder.tracks { try CoverMetadata.write(url, to: track.url) }
|
|
} else { try CoverMetadata.write(url, to: editingTrack?.url) }
|
|
editingFolderNumber = nil
|
|
app.library.reload()
|
|
} catch { app.report(error) }
|
|
}
|
|
.sheet(isPresented: $showMetadata) {
|
|
if let track = editingTrack { MetadataEditor(track: track) { metadata in
|
|
do { try ID3Metadata.write(metadata, to: track.url); app.library.reload(); return true }
|
|
catch { app.report(error); return false }
|
|
}}
|
|
}
|
|
.sheet(isPresented: $showTagging) {
|
|
MusicBrainzTaggingView(tracks: taggingTracks) { app.library.reload() }
|
|
}
|
|
.sheet(isPresented: $showAcoustIDTagging) {
|
|
AcoustIDTaggingView(tracks: taggingTracks) { app.library.reload() }
|
|
}
|
|
.sheet(isPresented: $showCardWriter) {
|
|
if let folder = app.library.currentFolder {
|
|
FolderCardWriterView(folder: folder)
|
|
}
|
|
}
|
|
.sheet(isPresented: $showYouTubeImport) {
|
|
if let folder = app.library.currentFolder {
|
|
YouTubeImportView(folderNumber: folder.number) { }
|
|
}
|
|
}
|
|
.sheet(item: $folderToRename) { folder in
|
|
FolderNameEditor(folder: folder) { name in
|
|
do { try app.library.renameFolder(folder.number, to: name); return true }
|
|
catch { app.report(error); return false }
|
|
}
|
|
}
|
|
.sheet(isPresented: $showCreateProfile) { LocalProfileCreateView() }
|
|
.sheet(isPresented: $showProfileManager) { ProfileManagementView() }
|
|
.sheet(item: $profileToRename) { profile in
|
|
LocalProfileRenameView(profile: profile) { name in
|
|
do { try app.library.renameProfile(profile.id, to: name); return true }
|
|
catch { app.report(error); return false }
|
|
}
|
|
}
|
|
.sheet(isPresented: $showSyncProfile) {
|
|
if let profile = app.library.activeProfile { ProfileSyncView(profile: profile) }
|
|
}
|
|
.confirmationDialog("Lokales Profil löschen?", isPresented: $showDeleteProfileConfirm, titleVisibility: .visible) {
|
|
Button("Profil und lokale Dateien in den Papierkorb verschieben", role: .destructive) {
|
|
guard let profile = app.library.activeProfile else { return }
|
|
do { try app.library.deleteProfile(profile.id); app.status = "Profil \(profile.name) gelöscht" }
|
|
catch { app.report(error) }
|
|
}
|
|
Button("Abbrechen", role: .cancel) { }
|
|
} message: {
|
|
Text("Das lokale Profil einschließlich aller Audiodateien wird in den macOS-Papierkorb verschoben.")
|
|
}
|
|
.confirmationDialog("SD-Karte bereinigen?", isPresented: $showCleanConfirm, titleVisibility: .visible) {
|
|
Button("Bereinigen", role: .destructive) { do { try app.library.cleanCard(); app.status = "SD-Karte bereinigt" } catch { app.report(error) } }
|
|
Button("Abbrechen", role: .cancel) { }
|
|
} message: {
|
|
let count = app.library.cleanPreview.map { $0.rootItems.count + $0.malformedFiles.count } ?? 0
|
|
Text("Es werden \(count) nicht zum TonUINO-Schema passende Einträge entfernt. Ordnernummern bleiben erhalten; Tracks werden neu nummeriert.")
|
|
}
|
|
}
|
|
|
|
private var mountedVolumesView: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text("Eingehängte Laufwerke").font(.headline)
|
|
Text("TonUINO-Treffer werden anhand der Ordnerstruktur geschätzt.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
if app.library.isDiscoveringVolumes { ProgressView().controlSize(.small) }
|
|
Button("Aktualisieren", systemImage: "arrow.clockwise") {
|
|
app.library.refreshMountedVolumes()
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 10)
|
|
|
|
Divider()
|
|
|
|
if app.library.mountedVolumes.isEmpty {
|
|
EmptyState(
|
|
title: app.library.isDiscoveringVolumes ? "Laufwerke werden gesucht…" : "Keine Laufwerke gefunden",
|
|
icon: "externaldrive",
|
|
detail: app.library.isDiscoveringVolumes ? "" : "Schließe eine SD-Karte an oder aktualisiere die Laufwerksliste."
|
|
)
|
|
} else {
|
|
List(app.library.mountedVolumes) { volume in
|
|
HStack(alignment: .center, spacing: 12) {
|
|
Image(systemName: volume.match == .likely ? "sdcard.fill" : "externaldrive")
|
|
.font(.title3)
|
|
.foregroundStyle(matchColor(volume.match))
|
|
.frame(width: 28)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(volume.name)
|
|
.font(.headline)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
Text(volume.url.path)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
.textSelection(.enabled)
|
|
Text(matchTitle(volume.match))
|
|
.font(.caption)
|
|
.foregroundStyle(matchColor(volume.match))
|
|
.lineLimit(1)
|
|
Text(matchDetail(volume.match))
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.layoutPriority(1)
|
|
Button("Öffnen") {
|
|
app.library.open(volume.url)
|
|
app.status = "SD-Karte wird eingelesen…"
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.fixedSize(horizontal: true, vertical: false)
|
|
}
|
|
.padding(.vertical, 5)
|
|
}
|
|
.listStyle(.inset)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func matchTitle(_ match: TonuinoVolumeMatch) -> String {
|
|
switch match {
|
|
case .likely: "Wahrscheinliche TonUINO-Karte"
|
|
case .possible: "Möglicher Treffer"
|
|
case .notRecognized: "Kein TonUINO-Muster"
|
|
case .unreadable: "Nicht lesbar"
|
|
}
|
|
}
|
|
|
|
private func matchDetail(_ match: TonuinoVolumeMatch) -> String {
|
|
switch match {
|
|
case .likely: "mp3 und advert mit Audiodateien"
|
|
case .possible: "Teilweise passendes Ordnerschema"
|
|
case .notRecognized: "Laufwerk kann manuell geöffnet werden"
|
|
case .unreadable: "Zugriff auf den Inhalt nicht möglich"
|
|
}
|
|
}
|
|
|
|
private func matchColor(_ match: TonuinoVolumeMatch) -> Color {
|
|
switch match {
|
|
case .likely: .green
|
|
case .possible: .orange
|
|
case .notRecognized, .unreadable: .secondary
|
|
}
|
|
}
|
|
|
|
private var folderSidebar: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Text("Ordner").font(.headline)
|
|
Spacer()
|
|
Button { do { try app.library.createFolder() } catch { app.report(error) } } label: { Image(systemName: "plus") }
|
|
.help("Ordner anlegen")
|
|
}.padding(.horizontal).padding(.top, 12)
|
|
List(selection: $app.library.selectedFolder) {
|
|
ForEach(app.library.folders) { folder in
|
|
let isEmpty = folder.tracks.isEmpty
|
|
HStack {
|
|
CoverThumbnail(url: folder.tracks.first?.url)
|
|
.onTapGesture { editingFolderNumber = folder.number; editingTrack = nil; showCoverPicker = true }
|
|
.help("Cover für alle Tracks dieses Ordners ändern")
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
HStack(spacing: 6) {
|
|
Text(folder.title)
|
|
.foregroundStyle(isEmpty ? Color.secondary : Color.primary)
|
|
.lineLimit(1)
|
|
if isEmpty {
|
|
Label("Leer", systemImage: "circle.slash")
|
|
.font(.caption2.weight(.medium))
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
Text("Ordner \(String(format: "%02d", folder.number)) · \(folder.tracks.count) Tracks")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary.opacity(isEmpty ? 0.75 : 1))
|
|
}
|
|
}.tag(folder.number)
|
|
.contextMenu {
|
|
Button("Ordner benennen…", systemImage: "pencil") { folderToRename = folder }
|
|
Button("Ordner löschen", role: .destructive) { do { try app.library.deleteFolder(folder.number) } catch { app.report(error) } }
|
|
}
|
|
}
|
|
Section("Systemordner") {
|
|
ForEach(app.library.systemFolders) { folder in
|
|
Button {
|
|
app.library.selectedFolder = nil
|
|
app.library.selectedSystemFolderName = folder.name
|
|
} label: {
|
|
Label("\(folder.name) · \(folder.tracks.count) Tracks", systemImage: "folder")
|
|
.foregroundStyle(app.library.selectedSystemFolderName == folder.name ? Color.accentColor : Color.primary)
|
|
}.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.plain)
|
|
.scrollContentBackground(.hidden)
|
|
Button("Karte bereinigen…", systemImage: "sparkles") {
|
|
do { _ = try app.library.cleanPreviewForCard(); showCleanConfirm = true } catch { app.report(error) }
|
|
}.disabled(app.library.isLoading).padding(.horizontal).padding(.bottom, 10)
|
|
}
|
|
}
|
|
|
|
private var trackPanel: some View {
|
|
VStack(spacing: 0) {
|
|
folderHeader.padding(.horizontal, 16).padding(.vertical, 12)
|
|
Divider()
|
|
HStack(spacing: 0) {
|
|
trackContent
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
Divider()
|
|
trackActions
|
|
.frame(width: 200)
|
|
.frame(maxHeight: .infinity, alignment: .topLeading)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var folderHeader: some View {
|
|
HStack(alignment: .center, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(spacing: 8) {
|
|
if isEditingFolderName, app.library.currentFolder != nil {
|
|
TextField("Ordnername", text: $editedFolderName)
|
|
.font(.title2.bold())
|
|
.textFieldStyle(.roundedBorder)
|
|
.frame(maxWidth: 420)
|
|
.onSubmit { saveFolderName() }
|
|
.accessibilityLabel("Ordnername")
|
|
Button("Sichern", systemImage: "checkmark") { saveFolderName() }
|
|
.controlSize(.small)
|
|
Button("Abbrechen", systemImage: "xmark") { cancelFolderNameEdit() }
|
|
.labelStyle(.iconOnly)
|
|
.buttonStyle(.plain)
|
|
.help("Änderung verwerfen")
|
|
} else {
|
|
Text(app.library.currentFolder?.title ?? app.library.currentSystemFolder?.name ?? "Ordner auswählen")
|
|
.font(.title2.bold())
|
|
.lineLimit(1)
|
|
.textSelection(.enabled)
|
|
if let folder = app.library.currentFolder {
|
|
Button("Bearbeiten", systemImage: "pencil") {
|
|
editedFolderName = folder.customName ?? folder.title
|
|
isEditingFolderName = true
|
|
}
|
|
.controlSize(.small)
|
|
.help("Ordnernamen bearbeiten")
|
|
}
|
|
}
|
|
}
|
|
Text(app.library.currentFolder != nil ? "\(app.library.currentFolder?.tracks.count ?? 0) von maximal 255 Tracks" : "Systemordner · nur Anzeige und Wiedergabe")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
|
|
private var trackActions: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Aktionen").font(.headline).padding(.bottom, 2)
|
|
if app.library.currentFolder != nil {
|
|
Menu {
|
|
Button("Alle Tracks auswählen") {
|
|
app.library.selectedTrackIDs = Set(app.library.currentTracks.map(\.id))
|
|
}.keyboardShortcut("a", modifiers: [.command])
|
|
Button("Auswahl aufheben") { app.library.selectedTrackIDs.removeAll() }
|
|
} label: {
|
|
Label("Auswahl", systemImage: "checkmark.circle")
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
|
|
if !app.library.selectedTrackIDs.isEmpty {
|
|
Button("Löschen", systemImage: "trash", role: .destructive) {
|
|
do { try app.library.deleteTracks(app.library.selectedTrackIDs) }
|
|
catch { app.report(error) }
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
Button("Nach oben", systemImage: "arrow.up") {
|
|
do { try app.library.moveSelectedTracks(by: -1) }
|
|
catch { app.report(error) }
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
|
|
Button("Nach unten", systemImage: "arrow.down") {
|
|
do { try app.library.moveSelectedTracks(by: 1) }
|
|
catch { app.report(error) }
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
Divider().padding(.vertical, 3)
|
|
|
|
Button("MusicBrainz taggen…", systemImage: "tag") {
|
|
prepareTagging(usingAcoustID: false)
|
|
}
|
|
.disabled(app.library.currentTracks.isEmpty || app.library.isLoading)
|
|
.help(app.library.selectedTrackIDs.isEmpty ? "Alle Tracks dieses Ordners taggen" : "Ausgewählte Tracks taggen")
|
|
|
|
Button("AcoustID identifizieren…", systemImage: "waveform") {
|
|
prepareTagging(usingAcoustID: true)
|
|
}
|
|
.disabled(app.library.currentTracks.isEmpty || app.library.isLoading)
|
|
.help(app.library.selectedTrackIDs.isEmpty ? "Alle Tracks dieses Ordners identifizieren" : "Ausgewählte Tracks identifizieren")
|
|
|
|
Button("NFC-Karte schreiben…", systemImage: "wave.3.right") { showCardWriter = true }
|
|
.disabled(app.library.isLoading)
|
|
.help("Ordner \(String(format: "%02d", app.library.currentFolder?.number ?? 0)) auf eine NFC-Karte schreiben")
|
|
|
|
Menu {
|
|
Button("Audiodateien…", systemImage: "music.note") { showImporter = true }
|
|
Button("YouTube-Video…", systemImage: "play.rectangle") { showYouTubeImport = true }
|
|
} label: {
|
|
Label("Musik hinzufügen…", systemImage: "plus")
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.disabled(app.library.isLoading)
|
|
} else {
|
|
Text("Wähle einen Musikordner aus, um Aktionen anzuzeigen.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.padding(12)
|
|
}
|
|
|
|
private var trackContent: some View {
|
|
Group {
|
|
if app.library.isLoading {
|
|
VStack(spacing: 12) {
|
|
ProgressView()
|
|
Text(app.library.scanProgress.isEmpty ? "SD-Karte wird eingelesen…" : app.library.scanProgress)
|
|
.foregroundStyle(.secondary).monospacedDigit()
|
|
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
} else if let scanError = app.library.scanError {
|
|
EmptyState(
|
|
title: app.library.activeProfile == nil ? "SD-Karte konnte nicht gelesen werden" : "Profil-Speicherort nicht verfügbar",
|
|
icon: "exclamationmark.triangle",
|
|
detail: scanError
|
|
)
|
|
} else {
|
|
let tracks = app.library.currentTracks
|
|
if !tracks.isEmpty {
|
|
Table(tracks, selection: $app.library.selectedTrackIDs) {
|
|
TableColumn("Nr.") { track in
|
|
Text(track.url.deletingPathExtension().lastPathComponent)
|
|
.foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing)
|
|
.background(activeTrackTint(for: track))
|
|
}.width(50)
|
|
TableColumn("Titel") { track in
|
|
HStack(spacing: 6) {
|
|
if isActiveTrack(track) {
|
|
if player.isLoading {
|
|
ProgressView().controlSize(.small).frame(width: 15)
|
|
} else {
|
|
Image(systemName: player.isPlaying ? "speaker.wave.2.fill" : "play.circle.fill")
|
|
.foregroundStyle(.tint)
|
|
.frame(width: 15)
|
|
.help(player.isPlaying ? "Wird gerade abgespielt" : "Aktueller Track, pausiert")
|
|
}
|
|
} else {
|
|
Color.clear.frame(width: 15, height: 12)
|
|
}
|
|
Text(track.displayTitle).lineLimit(1)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
.background(activeTrackTint(for: track))
|
|
}
|
|
TableColumn("Interpret") { track in
|
|
Text(track.artist).lineLimit(1)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
.background(activeTrackTint(for: track))
|
|
}
|
|
TableColumn("Album") { track in
|
|
Text(track.album).lineLimit(1)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
.background(activeTrackTint(for: track))
|
|
}
|
|
TableColumn("") { track in
|
|
HStack(spacing: 8) {
|
|
Button {
|
|
if isActiveTrack(track) {
|
|
player.toggle()
|
|
} else if let index = tracks.firstIndex(of: track) {
|
|
player.play(tracks, startingAt: index)
|
|
}
|
|
} label: {
|
|
Image(systemName: isActiveTrack(track) && player.isPlaying ? "pause.fill" : "play.fill")
|
|
.frame(width: 18, height: 18)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help(isActiveTrack(track) && player.isPlaying ? "Pausieren" : isActiveTrack(track) ? "Weiter abspielen" : "Abspielen")
|
|
if app.library.currentFolder != nil {
|
|
Menu {
|
|
Button("Metadaten bearbeiten/anlegen…") { editingTrack = track; showMetadata = true }
|
|
Button("Cover einbetten…") { editingTrack = track; showCoverPicker = true }
|
|
if let index = tracks.firstIndex(of: track), index > 0 { Button("Nach oben") { do { try app.library.moveTrack(from: index, to: index - 1) } catch { app.report(error) } } }
|
|
if let index = tracks.firstIndex(of: track), index + 1 < tracks.count { Button("Nach unten") { do { try app.library.moveTrack(from: index, to: index + 1) } catch { app.report(error) } } }
|
|
Button("Track löschen", role: .destructive) { do { try app.library.deleteTracks([track.id]) } catch { app.report(error) } }
|
|
} label: { Image(systemName: "ellipsis") }.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
|
.background(activeTrackTint(for: track))
|
|
}.width(70)
|
|
}
|
|
} else {
|
|
EmptyState(title: "Noch keine Musik", icon: "music.note.list", detail: "Füge MP3s oder andere Audiodateien hinzu. Nicht-MP3-Dateien werden mit dem gebündelten FFmpeg konvertiert.")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func prepareTagging(usingAcoustID: Bool) {
|
|
let folderTracks = app.library.currentTracks
|
|
taggingTracks = app.library.selectedTrackIDs.isEmpty
|
|
? folderTracks
|
|
: folderTracks.filter { app.library.selectedTrackIDs.contains($0.id) }
|
|
if usingAcoustID { showAcoustIDTagging = true }
|
|
else { showTagging = true }
|
|
}
|
|
|
|
private func isActiveTrack(_ track: MusicTrack) -> Bool {
|
|
player.currentURL?.standardizedFileURL == track.url.standardizedFileURL
|
|
}
|
|
|
|
private func activeTrackTint(for track: MusicTrack) -> Color {
|
|
isActiveTrack(track) ? Color.accentColor.opacity(0.10) : .clear
|
|
}
|
|
|
|
private func saveFolderName() {
|
|
guard let folder = app.library.currentFolder else { return }
|
|
do {
|
|
try app.library.renameFolder(folder.number, to: editedFolderName)
|
|
isEditingFolderName = false
|
|
app.status = "Ordnername gespeichert"
|
|
} catch { app.report(error) }
|
|
}
|
|
|
|
private func cancelFolderNameEdit() {
|
|
editedFolderName = app.library.currentFolder?.customName ?? app.library.currentFolder?.title ?? ""
|
|
isEditingFolderName = false
|
|
}
|
|
|
|
private func chooseCard() {
|
|
let panel = NSOpenPanel(); panel.canChooseFiles = false; panel.canChooseDirectories = true; panel.allowsMultipleSelection = false
|
|
panel.message = "Wähle das Volume der TonUINO-SD-Karte aus."
|
|
if panel.runModal() == .OK, let url = panel.url {
|
|
app.library.open(url)
|
|
app.status = "SD-Karte wird eingelesen…"
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct LocalProfileCreateView: View {
|
|
@EnvironmentObject private var app: AppModel
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var name = "Neue TonUINO-Box"
|
|
@State private var sourceID = ""
|
|
@State private var storageBaseURL: URL?
|
|
@State private var isCreating = false
|
|
|
|
private var candidateVolumes: [TonuinoMountedVolume] {
|
|
app.library.mountedVolumes.filter { $0.isRemovable || $0.match == .likely || $0.match == .possible }
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text("Lokales Profil erstellen").font(.title2.bold())
|
|
Text("Das Profil speichert eine eigene Kopie der TonUINO-Karteninhalte auf diesem Mac.")
|
|
.foregroundStyle(.secondary)
|
|
Form {
|
|
TextField("Profilname", text: $name)
|
|
Picker("Startinhalt", selection: $sourceID) {
|
|
Text("Leere Bibliothek").tag("")
|
|
ForEach(candidateVolumes) { volume in
|
|
Text("Von \(volume.name) kopieren · \(volume.url.path)").tag(volume.id)
|
|
}
|
|
}
|
|
.pickerStyle(.menu)
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Speicherort")
|
|
Text(storageBaseURL?.path ?? app.library.profileStorageURL.path)
|
|
.font(.system(.caption, design: .monospaced))
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
.lineLimit(3)
|
|
HStack {
|
|
Button(storageBaseURL == nil ? "Anderen Ordner auswählen…" : "Ordner ändern…", systemImage: "folder") {
|
|
chooseStorageLocation()
|
|
}
|
|
if storageBaseURL != nil {
|
|
Button("Standardort", systemImage: "arrow.uturn.backward") { storageBaseURL = nil }
|
|
}
|
|
}
|
|
Text("Im ausgewählten Ordner wird „Tonuino Manager Profiles“ für dieses Profil angelegt.")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
if isCreating || app.library.isSyncing {
|
|
HStack(spacing: 10) {
|
|
ProgressView().controlSize(.small)
|
|
Text(app.library.syncProgress.isEmpty ? "Profil wird erstellt…" : app.library.syncProgress)
|
|
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
|
}
|
|
}
|
|
HStack {
|
|
Spacer()
|
|
Button("Abbrechen") { dismiss() }.keyboardShortcut(.cancelAction)
|
|
Button("Profil erstellen") { create() }
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isCreating || app.library.isSyncing)
|
|
}
|
|
}
|
|
.padding(24)
|
|
.frame(width: 540)
|
|
}
|
|
|
|
private func create() {
|
|
let source = candidateVolumes.first { $0.id == sourceID }?.url
|
|
isCreating = true
|
|
Task {
|
|
defer { isCreating = false }
|
|
do {
|
|
try await app.library.createProfile(name: name, copyingFrom: source, storageBaseURL: storageBaseURL)
|
|
app.status = "Lokales Profil \(name) erstellt"
|
|
dismiss()
|
|
} catch { app.report(error) }
|
|
}
|
|
}
|
|
|
|
private func chooseStorageLocation() {
|
|
let panel = NSOpenPanel()
|
|
panel.title = "Speicherort für dieses Profil auswählen"
|
|
panel.prompt = "Diesen Ordner verwenden"
|
|
panel.canChooseFiles = false
|
|
panel.canChooseDirectories = true
|
|
panel.canCreateDirectories = true
|
|
panel.allowsMultipleSelection = false
|
|
if panel.runModal() == .OK { storageBaseURL = panel.url }
|
|
}
|
|
}
|
|
|
|
private struct LocalProfileRenameView: View {
|
|
let profile: LibraryProfile
|
|
let save: (String) -> Bool
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var name: String
|
|
|
|
init(profile: LibraryProfile, save: @escaping (String) -> Bool) {
|
|
self.profile = profile
|
|
self.save = save
|
|
_name = State(initialValue: profile.name)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text("Profil umbenennen").font(.title2.bold())
|
|
TextField("Profilname", text: $name).textFieldStyle(.roundedBorder)
|
|
HStack {
|
|
Spacer()
|
|
Button("Abbrechen") { dismiss() }.keyboardShortcut(.cancelAction)
|
|
Button("Sichern") { if save(name) { dismiss() } }
|
|
.buttonStyle(.borderedProminent)
|
|
.keyboardShortcut(.defaultAction)
|
|
.disabled(name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
|
}
|
|
}
|
|
.padding(24)
|
|
.frame(width: 420)
|
|
}
|
|
}
|
|
|
|
private enum ProfileManagerSheet: Identifiable {
|
|
case create
|
|
case rename(LibraryProfile)
|
|
case compare(LibraryProfile)
|
|
|
|
var id: String {
|
|
switch self {
|
|
case .create: "create"
|
|
case .rename(let profile): "rename-\(profile.id.uuidString)"
|
|
case .compare(let profile): "compare-\(profile.id.uuidString)"
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ProfileManagementView: View {
|
|
@EnvironmentObject private var app: AppModel
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var presentedSheet: ProfileManagerSheet?
|
|
@State private var profileToDelete: LibraryProfile?
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Profile verwalten").font(.title2.bold())
|
|
Text("Prüfe Speicherorte und vergleiche lokale Profile mit einer eingelegten SD-Karte. Der Vergleich liest Dateiinformationen; er schreibt nichts auf die Karte.")
|
|
.font(.callout).foregroundStyle(.secondary)
|
|
|
|
if app.library.profiles.isEmpty {
|
|
EmptyState(title: "Noch keine Profile", icon: "externaldrive.badge.person.crop", detail: "Erstelle ein lokales Profil, um eine Bibliothek unabhängig von der SD-Karte zu verwalten.")
|
|
} else {
|
|
List(app.library.profiles) { profile in
|
|
profileRow(profile)
|
|
}
|
|
.listStyle(.inset)
|
|
}
|
|
|
|
HStack {
|
|
Button("Profil erstellen…", systemImage: "plus") { presentedSheet = .create }
|
|
Spacer()
|
|
Button("Fertig") { dismiss() }.keyboardShortcut(.cancelAction)
|
|
}
|
|
}
|
|
.padding(20)
|
|
.frame(width: 860, height: 560)
|
|
.sheet(item: $presentedSheet) { sheet in
|
|
switch sheet {
|
|
case .create:
|
|
LocalProfileCreateView()
|
|
case .rename(let profile):
|
|
LocalProfileRenameView(profile: profile) { name in
|
|
do { try app.library.renameProfile(profile.id, to: name); return true }
|
|
catch { app.report(error); return false }
|
|
}
|
|
case .compare(let profile):
|
|
ProfileSyncView(profile: profile)
|
|
}
|
|
}
|
|
.confirmationDialog(
|
|
"Lokales Profil löschen?",
|
|
isPresented: Binding(
|
|
get: { profileToDelete != nil },
|
|
set: { if !$0 { profileToDelete = nil } }
|
|
),
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Profil und lokale Dateien in den Papierkorb verschieben", role: .destructive) {
|
|
guard let profile = profileToDelete else { return }
|
|
do {
|
|
try app.library.deleteProfile(profile.id)
|
|
app.status = "Profil \(profile.name) gelöscht"
|
|
} catch { app.report(error) }
|
|
profileToDelete = nil
|
|
}
|
|
Button("Abbrechen", role: .cancel) { profileToDelete = nil }
|
|
} message: {
|
|
Text("Das Profil wird aus der Übersicht entfernt. Seine lokalen Dateien werden in den macOS-Papierkorb verschoben.")
|
|
}
|
|
}
|
|
|
|
private func profileRow(_ profile: LibraryProfile) -> some View {
|
|
let isAvailable = FileManager.default.fileExists(atPath: profile.cardRootURL.path)
|
|
return HStack(alignment: .center, spacing: 14) {
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
HStack(spacing: 8) {
|
|
Text(profile.name).font(.headline)
|
|
if app.library.activeProfileID == profile.id {
|
|
Text("Geöffnet").font(.caption2).padding(.horizontal, 6).padding(.vertical, 2)
|
|
.background(.quaternary, in: Capsule())
|
|
}
|
|
Label(isAvailable ? "Daten verfügbar" : "Speicherort nicht verfügbar",
|
|
systemImage: isAvailable ? "checkmark.circle.fill" : "externaldrive.badge.exclamationmark")
|
|
.font(.caption)
|
|
.foregroundStyle(isAvailable ? Color.green : Color.orange)
|
|
}
|
|
Text(profile.cardRootURL.path)
|
|
.font(.system(.caption2, design: .monospaced))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
.textSelection(.enabled)
|
|
}
|
|
Spacer(minLength: 6)
|
|
Button("Öffnen", systemImage: "arrow.up.right.square") {
|
|
app.library.openProfile(profile.id)
|
|
app.status = "Lokales Profil \(profile.name) geöffnet"
|
|
dismiss()
|
|
}
|
|
Button("Vergleichen…", systemImage: "arrow.left.arrow.right") {
|
|
presentedSheet = .compare(profile)
|
|
}
|
|
Menu {
|
|
Button("Umbenennen…", systemImage: "pencil") { presentedSheet = .rename(profile) }
|
|
Button("Löschen…", systemImage: "trash", role: .destructive) { profileToDelete = profile }
|
|
} label: {
|
|
Image(systemName: "ellipsis.circle")
|
|
}
|
|
.menuStyle(.borderlessButton)
|
|
.help("Weitere Profilaktionen")
|
|
}
|
|
.padding(.vertical, 5)
|
|
}
|
|
}
|
|
|
|
private struct ProfileSyncView: View {
|
|
let profile: LibraryProfile
|
|
@EnvironmentObject private var app: AppModel
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var selectedVolumeID = ""
|
|
@State private var preview: LibrarySyncPreview?
|
|
@State private var isPreviewing = false
|
|
@State private var showSyncConfirmation = false
|
|
|
|
private var candidateVolumes: [TonuinoMountedVolume] {
|
|
app.library.mountedVolumes.filter { $0.isRemovable || $0.match == .likely || $0.match == .possible }
|
|
}
|
|
private var selectedVolume: TonuinoMountedVolume? { candidateVolumes.first { $0.id == selectedVolumeID } }
|
|
private var changesToShow: [LibrarySyncChange] { Array((preview?.changes ?? []).prefix(500)) }
|
|
private var requiresExplicitRebind: Bool { preview?.requiresRebinding == true }
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text("Profil vergleichen und reparieren").font(.title2.bold())
|
|
Text("Vergleiche „\(profile.name)“ mit einer SD-Karte. Du kannst fehlende lokale Dateien ergänzen oder das lokale Profil ausdrücklich auf die Karte anwenden.")
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
|
|
HStack {
|
|
Picker("Zielkarte", selection: $selectedVolumeID) {
|
|
Text("SD-Karte auswählen").tag("")
|
|
ForEach(candidateVolumes) { volume in
|
|
Text("\(volume.name) · \(volume.url.path)").tag(volume.id)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
Button("Aktualisieren", systemImage: "arrow.clockwise") { app.library.refreshMountedVolumes() }
|
|
}
|
|
.disabled(isPreviewing || app.library.isSyncing)
|
|
|
|
if let preview {
|
|
GroupBox {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Ziel: \(preview.targetName) · \(preview.targetURL.path)")
|
|
.font(.caption).foregroundStyle(.secondary).textSelection(.enabled)
|
|
HStack(spacing: 14) {
|
|
Label("\(preview.profileOnlyFileCount) nur lokal", systemImage: "internaldrive")
|
|
Label("\(preview.cardOnlyFileCount) nur auf SD", systemImage: "sdcard")
|
|
Label("\(preview.conflictingFileCount) abweichend", systemImage: "exclamationmark.circle")
|
|
}
|
|
.font(.caption)
|
|
Text("Vergleich anhand von Pfad, Dateigröße und Änderungszeit. Es wurden keine vollständigen Audiodateien gelesen und nichts auf die SD-Karte geschrieben.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
if requiresExplicitRebind {
|
|
Label("Diese SD-Karte ist bisher einem anderen Profil zugeordnet. Die Bestätigung ordnet „\(profile.name)“ dieser Karte neu zu.", systemImage: "exclamationmark.triangle.fill")
|
|
.font(.caption).foregroundStyle(.orange).fixedSize(horizontal: false, vertical: true)
|
|
} else if preview.requiresBinding {
|
|
Label(preview.targetUUID == nil
|
|
? "Die Karte stellt keine Volume-UUID bereit. Bitte prüfe den Zielpfad bei jedem Abgleich sorgfältig."
|
|
: "Mit der Bestätigung wird dieses Profil dauerhaft dieser SD-Karte zugeordnet.",
|
|
systemImage: "info.circle")
|
|
.font(.caption).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
if preview.changes.isEmpty {
|
|
Label("Keine Unterschiede bei Pfad, Dateigröße und Änderungszeit gefunden.", systemImage: "checkmark.circle")
|
|
.font(.caption).foregroundStyle(.green)
|
|
} else {
|
|
List(changesToShow) { change in
|
|
HStack(spacing: 8) {
|
|
Image(systemName: change.isDirectory ? "folder" : "doc")
|
|
.foregroundStyle(change.operation == .updated ? .orange : .secondary)
|
|
Text(changeLabel(change)).frame(width: 100, alignment: .leading)
|
|
.foregroundStyle(change.operation == .updated ? .orange : .secondary)
|
|
Text(change.path).lineLimit(1).truncationMode(.middle)
|
|
Spacer(minLength: 4)
|
|
if !change.isDirectory {
|
|
Text(ByteCountFormatter.string(fromByteCount: change.size, countStyle: .file))
|
|
.font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.font(.caption)
|
|
.padding(.vertical, 2)
|
|
}
|
|
if preview.changes.count > changesToShow.count {
|
|
Text("Weitere \(preview.changes.count - changesToShow.count) Einträge sind in der Vorschau enthalten.")
|
|
.font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
} else if candidateVolumes.isEmpty {
|
|
EmptyState(title: "Keine SD-Karte gefunden", icon: "sdcard", detail: "Schließe eine Karte an und aktualisiere die Laufwerksliste.")
|
|
.frame(height: 150)
|
|
} else {
|
|
Text("Die Vorschau vergleicht Dateigröße und Änderungszeit. Sie liest keine kompletten Audiodateien ein und belastet große SD-Karten dadurch deutlich weniger.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
.frame(maxWidth: .infinity, minHeight: 120, alignment: .center)
|
|
}
|
|
|
|
if isPreviewing || app.library.isSyncing {
|
|
HStack(spacing: 10) {
|
|
ProgressView().controlSize(.small)
|
|
Text(app.library.syncProgress.isEmpty ? "Bitte warten…" : app.library.syncProgress)
|
|
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
|
}
|
|
}
|
|
|
|
HStack {
|
|
Spacer()
|
|
Button("Schließen") { dismiss() }.keyboardShortcut(.cancelAction)
|
|
if preview == nil {
|
|
Button("Vergleich starten") { makePreview() }
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(selectedVolume == nil || isPreviewing || app.library.isSyncing)
|
|
} else {
|
|
Button("Andere Karte wählen") { preview = nil }
|
|
.disabled(isPreviewing || app.library.isSyncing)
|
|
if (preview?.cardOnlyFileCount ?? 0) > 0 {
|
|
Button("Fehlende lokale Dateien ergänzen…", systemImage: "arrow.down.to.line") {
|
|
restoreMissingFiles()
|
|
}
|
|
.disabled(isPreviewing || app.library.isSyncing)
|
|
}
|
|
if preview?.changes.isEmpty == true && preview?.requiresBinding == false && preview?.requiresRebinding == false {
|
|
Button("Fertig") { dismiss() }
|
|
} else {
|
|
Button(requiresExplicitRebind ? "Profil auf SD neu zuordnen…" : "Lokales Profil auf SD anwenden…", systemImage: "arrow.up.to.line") {
|
|
showSyncConfirmation = true
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(app.library.isSyncing)
|
|
.confirmationDialog("Lokales Profil auf SD-Karte anwenden?", isPresented: $showSyncConfirmation, titleVisibility: .visible) {
|
|
Button(requiresExplicitRebind ? "Neu zuordnen und anwenden" : "Auf SD anwenden", role: .destructive) { synchronize() }
|
|
Button("Abbrechen", role: .cancel) { }
|
|
} message: {
|
|
let removalCount = preview?.removedCount ?? 0
|
|
Text("Die SD-Karte „\(preview?.targetName ?? "")“ wird an das lokale Profil angeglichen. \(removalCount) Einträge, die im Profil fehlen, werden auf der SD-Karte gelöscht.")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(22)
|
|
.frame(width: 760, height: 650)
|
|
.onAppear {
|
|
if selectedVolumeID.isEmpty, candidateVolumes.count == 1 { selectedVolumeID = candidateVolumes[0].id }
|
|
}
|
|
.onChange(of: selectedVolumeID) { _ in preview = nil }
|
|
}
|
|
|
|
private func makePreview() {
|
|
guard let selectedVolume else { return }
|
|
isPreviewing = true
|
|
Task {
|
|
defer { isPreviewing = false }
|
|
do { preview = try await app.library.previewSync(profileID: profile.id, to: selectedVolume) }
|
|
catch { app.report(error) }
|
|
}
|
|
}
|
|
|
|
private func changeLabel(_ change: LibrarySyncChange) -> String {
|
|
switch change.operation {
|
|
case .added: "Nur lokal"
|
|
case .updated: "Abweichend"
|
|
case .removed: "Nur auf SD"
|
|
}
|
|
}
|
|
|
|
private func restoreMissingFiles() {
|
|
guard let preview else { return }
|
|
Task {
|
|
do {
|
|
let copiedCount = try await app.library.restoreMissingFiles(from: preview)
|
|
app.status = copiedCount == 0
|
|
? "Keine fehlenden lokalen Dateien mehr vorhanden"
|
|
: "\(copiedCount) Dateien in Profil \(profile.name) ergänzt"
|
|
self.preview = nil
|
|
} catch { app.report(error) }
|
|
}
|
|
}
|
|
|
|
private func synchronize() {
|
|
guard let preview else { return }
|
|
Task {
|
|
do {
|
|
try await app.library.synchronize(preview)
|
|
app.status = "Profil \(profile.name) mit \(preview.targetName) synchronisiert"
|
|
dismiss()
|
|
} catch { app.report(error) }
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct PersistentFolderSplitView<Sidebar: View, Detail: View>: NSViewRepresentable {
|
|
@Binding var sidebarWidth: Double
|
|
let sidebar: Sidebar
|
|
let detail: Detail
|
|
|
|
init(
|
|
sidebarWidth: Binding<Double>,
|
|
@ViewBuilder sidebar: () -> Sidebar,
|
|
@ViewBuilder detail: () -> Detail
|
|
) {
|
|
_sidebarWidth = sidebarWidth
|
|
self.sidebar = sidebar()
|
|
self.detail = detail()
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(sidebarWidth: $sidebarWidth)
|
|
}
|
|
|
|
func makeNSView(context: Context) -> NSSplitView {
|
|
let splitView = NSSplitView()
|
|
splitView.isVertical = true
|
|
splitView.dividerStyle = .thin
|
|
splitView.delegate = context.coordinator
|
|
|
|
let sidebarHost = NSHostingView(rootView: sidebar)
|
|
sidebarHost.autoresizingMask = [.height]
|
|
let detailHost = NSHostingView(rootView: detail)
|
|
detailHost.autoresizingMask = [.width, .height]
|
|
splitView.addSubview(sidebarHost)
|
|
splitView.addSubview(detailHost)
|
|
context.coordinator.sidebarHost = sidebarHost
|
|
context.coordinator.detailHost = detailHost
|
|
return splitView
|
|
}
|
|
|
|
func updateNSView(_ splitView: NSSplitView, context: Context) {
|
|
let coordinator = context.coordinator
|
|
coordinator.sidebarWidth = $sidebarWidth
|
|
coordinator.sidebarHost?.rootView = sidebar
|
|
coordinator.detailHost?.rootView = detail
|
|
|
|
guard !coordinator.didApplyInitialWidth else { return }
|
|
coordinator.didApplyInitialWidth = true
|
|
DispatchQueue.main.async { [weak splitView] in
|
|
guard let splitView else { return }
|
|
let proposed = CGFloat(self.sidebarWidth)
|
|
splitView.setPosition(proposed, ofDividerAt: 0)
|
|
splitView.adjustSubviews()
|
|
}
|
|
}
|
|
|
|
static func dismantleNSView(_ splitView: NSSplitView, coordinator: Coordinator) {
|
|
coordinator.pendingSave?.cancel()
|
|
splitView.delegate = nil
|
|
}
|
|
|
|
final class Coordinator: NSObject, NSSplitViewDelegate {
|
|
var sidebarWidth: Binding<Double>
|
|
weak var sidebarHost: NSHostingView<Sidebar>?
|
|
weak var detailHost: NSHostingView<Detail>?
|
|
var didApplyInitialWidth = false
|
|
var pendingSave: DispatchWorkItem?
|
|
|
|
init(sidebarWidth: Binding<Double>) {
|
|
self.sidebarWidth = sidebarWidth
|
|
}
|
|
|
|
func splitView(
|
|
_ splitView: NSSplitView,
|
|
constrainSplitPosition proposedPosition: CGFloat,
|
|
ofSubviewAt dividerIndex: Int
|
|
) -> CGFloat {
|
|
let minimumSidebar: CGFloat = 220
|
|
let minimumDetail: CGFloat = 380
|
|
let maximumSidebar = max(minimumSidebar, splitView.bounds.width - minimumDetail - splitView.dividerThickness)
|
|
return min(max(proposedPosition, minimumSidebar), min(520, maximumSidebar))
|
|
}
|
|
|
|
func splitViewDidResizeSubviews(_ notification: Notification) {
|
|
guard let splitView = notification.object as? NSSplitView else { return }
|
|
pendingSave?.cancel()
|
|
let work = DispatchWorkItem { [weak self, weak splitView] in
|
|
guard let self, let splitView else { return }
|
|
if splitView.inLiveResize {
|
|
self.splitViewDidResizeSubviews(Notification(name: NSSplitView.didResizeSubviewsNotification, object: splitView))
|
|
return
|
|
}
|
|
guard let sidebar = splitView.subviews.first else { return }
|
|
let width = Double(sidebar.frame.width)
|
|
guard abs(width - self.sidebarWidth.wrappedValue) >= 1 else { return }
|
|
self.sidebarWidth.wrappedValue = width
|
|
}
|
|
pendingSave = work
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work)
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct EmptyState: View {
|
|
let title: String
|
|
let icon: String
|
|
let detail: String
|
|
var body: some View {
|
|
VStack(spacing: 10) {
|
|
Image(systemName: icon).font(.system(size: 38)).foregroundStyle(.secondary)
|
|
Text(title).font(.title3.bold())
|
|
Text(detail).foregroundStyle(.secondary).multilineTextAlignment(.center).frame(maxWidth: 420)
|
|
}.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
private struct CoverThumbnail: View {
|
|
var url: URL?
|
|
var size: CGFloat = 36
|
|
@State private var image: NSImage?
|
|
var body: some View {
|
|
Group {
|
|
if let image { Image(nsImage: image).resizable().scaledToFill() }
|
|
else { Image(systemName: "music.note").resizable().scaledToFit().padding(8).foregroundStyle(.secondary) }
|
|
}.frame(width: size, height: size).background(.quaternary).clipShape(RoundedRectangle(cornerRadius: 7))
|
|
.task(id: url) {
|
|
guard let url else { image = nil; return }
|
|
image = await CoverImageCache.shared.image(for: url)?.image
|
|
}
|
|
}
|
|
}
|
|
|
|
private actor CoverImageCache {
|
|
static let shared = CoverImageCache()
|
|
private var images: [URL: CachedCoverImage] = [:]
|
|
|
|
func image(for url: URL) async -> CachedCoverImage? {
|
|
if let image = images[url] { return image }
|
|
let decoded = await Task.detached(priority: .utility) { CoverMetadata.read(from: url).map(CachedCoverImage.init) }.value
|
|
if let decoded { images[url] = decoded }
|
|
return decoded
|
|
}
|
|
}
|
|
|
|
private final class CachedCoverImage: @unchecked Sendable {
|
|
let image: NSImage
|
|
init(_ image: NSImage) { self.image = image }
|
|
}
|
|
|
|
private struct PlayerBar: View {
|
|
@ObservedObject var player: AudioPlayerModel
|
|
let tracks: [MusicTrack]
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
CoverThumbnail(url: player.currentURL, size: 52)
|
|
VStack(alignment: .leading, spacing: 3) {
|
|
Text(player.title.isEmpty ? "Kein Track ausgewählt" : player.title).lineLimit(1)
|
|
let showsStatus = player.isLoading || player.statusMessage.hasPrefix("Wiedergabe fehlgeschlagen")
|
|
Text(showsStatus || player.artist.isEmpty ? player.statusMessage : player.artist)
|
|
.font(.caption).foregroundStyle(player.statusMessage.hasPrefix("Wiedergabe fehlgeschlagen") ? .red : .secondary).lineLimit(1)
|
|
}.frame(width: 230, alignment: .leading)
|
|
Button { player.previous() } label: { Image(systemName: "backward.end.fill") }
|
|
.buttonStyle(.plain)
|
|
.font(.title2)
|
|
.frame(width: 42, height: 44)
|
|
.disabled(player.isLoading)
|
|
Button { toggleOrPlayFolder() } label: {
|
|
if player.isLoading { ProgressView().controlSize(.small) }
|
|
else { Image(systemName: player.isPlaying ? "pause.fill" : "play.fill") }
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.controlSize(.large)
|
|
.font(.title2.weight(.semibold))
|
|
.frame(width: 48, height: 44)
|
|
.clipShape(Circle())
|
|
.keyboardShortcut(.space, modifiers: [])
|
|
.help(player.isCurrentTrack(in: tracks) ? "Wiedergabe/Pause (Leertaste)" : "Ordner abspielen (Leertaste)")
|
|
.disabled(player.isLoading || tracks.isEmpty)
|
|
Button { player.next() } label: { Image(systemName: "forward.end.fill") }
|
|
.buttonStyle(.plain)
|
|
.font(.title2)
|
|
.frame(width: 42, height: 44)
|
|
.disabled(player.isLoading)
|
|
Text(time(player.position)).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
|
Slider(value: Binding(get: { player.position }, set: { player.seek($0) }), in: 0...max(player.duration, 1)).disabled(player.duration == 0)
|
|
Text(time(player.duration)).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
|
Image(systemName: "speaker.wave.2").foregroundStyle(.secondary)
|
|
Slider(value: $player.volume, in: 0...1).frame(width: 90)
|
|
}.buttonStyle(.plain)
|
|
}
|
|
private func toggleOrPlayFolder() {
|
|
if player.isCurrentTrack(in: tracks) {
|
|
player.toggle()
|
|
} else {
|
|
player.play(tracks, startingAt: 0)
|
|
}
|
|
}
|
|
private func time(_ seconds: Double) -> String { guard seconds.isFinite else { return "0:00" }; return String(format: "%d:%02d", Int(seconds) / 60, Int(seconds) % 60) }
|
|
}
|
|
|
|
private struct MetadataEditor: View {
|
|
let track: MusicTrack
|
|
let save: (TrackMetadata) -> Bool
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var title = ""
|
|
@State private var artist = ""
|
|
@State private var album = ""
|
|
@State private var albumArtist = ""
|
|
@State private var trackNumber = ""
|
|
@State private var discNumber = ""
|
|
@State private var year = ""
|
|
@State private var genre = ""
|
|
@State private var acoustID = ""
|
|
@State private var musicBrainzRecordingID = ""
|
|
@State private var musicBrainzReleaseID = ""
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text("Track-Metadaten bearbeiten oder anlegen").font(.title2.bold())
|
|
Text("Speichern legt bei Bedarf einen neuen ID3-Tag in der MP3-Datei an.").font(.caption).foregroundStyle(.secondary)
|
|
LabeledContent("Titel") { TextField("Titel", text: $title) }
|
|
LabeledContent("Interpret") { TextField("Interpret", text: $artist) }
|
|
LabeledContent("Album") { TextField("Album", text: $album) }
|
|
LabeledContent("Albuminterpret") { TextField("Albuminterpret", text: $albumArtist) }
|
|
HStack { LabeledContent("Track") { TextField("1", text: $trackNumber) }; LabeledContent("CD") { TextField("1", text: $discNumber) } }
|
|
HStack { LabeledContent("Jahr") { TextField("Jahr", text: $year) }; LabeledContent("Genre") { TextField("Genre", text: $genre) } }
|
|
LabeledContent("AcoustID-ID") { TextField("Optional", text: $acoustID) }
|
|
LabeledContent("MusicBrainz Recording-ID") { TextField("Optional", text: $musicBrainzRecordingID) }
|
|
LabeledContent("MusicBrainz Release-ID") { TextField("Optional", text: $musicBrainzReleaseID) }
|
|
HStack { Spacer(); Button("Abbrechen") { dismiss() }; Button("Sichern") {
|
|
if save(TrackMetadata(title: title, artist: artist, album: album, albumArtist: albumArtist, trackNumber: trackNumber, discNumber: discNumber, year: year, genre: genre, acoustID: acoustID, musicBrainzRecordingID: musicBrainzRecordingID, musicBrainzReleaseID: musicBrainzReleaseID)) { dismiss() }
|
|
}.keyboardShortcut(.defaultAction) }
|
|
}.padding(24).frame(width: 560).onAppear {
|
|
let tags = ID3Metadata.read(track.url)
|
|
title = tags.title; artist = tags.artist; album = tags.album; albumArtist = tags.albumArtist
|
|
trackNumber = tags.trackNumber; discNumber = tags.discNumber; year = tags.year; genre = tags.genre
|
|
acoustID = tags.acoustID
|
|
musicBrainzRecordingID = tags.musicBrainzRecordingID; musicBrainzReleaseID = tags.musicBrainzReleaseID
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct FolderNameEditor: View {
|
|
let folder: MusicFolder
|
|
let save: (String) -> Bool
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var name: String
|
|
|
|
init(folder: MusicFolder, save: @escaping (String) -> Bool) {
|
|
self.folder = folder
|
|
self.save = save
|
|
_name = State(initialValue: folder.customName ?? folder.title)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text("Ordner \(String(format: "%02d", folder.number)) benennen").font(.title2.bold())
|
|
TextField("Anzeigename", text: $name)
|
|
.textFieldStyle(.roundedBorder)
|
|
.onSubmit { if save(name) { dismiss() } }
|
|
Text("Der Name wird auf der SD-Karte gespeichert und bleibt beim nächsten Öffnen erhalten. Leeres Feld entfernt den eigenen Namen.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
HStack {
|
|
Spacer()
|
|
Button("Abbrechen") { dismiss() }.keyboardShortcut(.cancelAction)
|
|
Button("Sichern") { if save(name) { dismiss() } }.keyboardShortcut(.defaultAction)
|
|
}
|
|
}
|
|
.padding(24)
|
|
.frame(width: 460)
|
|
}
|
|
}
|
|
|
|
enum CoverMetadata {
|
|
static func read(from url: URL) -> NSImage? {
|
|
guard let data = try? Data(contentsOf: url, options: .mappedIfSafe), data.count > 10,
|
|
String(data: data.prefix(3), encoding: .ascii) == "ID3" else { return nil }
|
|
let tagEnd = min(data.count, 10 + data[6..<10].reduce(0) { ($0 << 7) | Int($1 & 0x7f) })
|
|
var i = 10
|
|
while i + 10 < tagEnd {
|
|
guard let id = String(data: data[i..<i+4], encoding: .ascii), id.utf8.allSatisfy({ $0 >= 0x20 && $0 <= 0x5A }) else { return nil }
|
|
let size = Int(data[i+4]) << 24 | Int(data[i+5]) << 16 | Int(data[i+6]) << 8 | Int(data[i+7]); i += 10
|
|
guard size > 0, i + size <= tagEnd else { return nil }
|
|
if id == "APIC" {
|
|
let frame = data[i..<i+size]; guard frame.count > 5 else { return nil }
|
|
let rest = Data(frame.dropFirst())
|
|
if let terminator = rest.firstIndex(of: 0), terminator + 2 < rest.endIndex {
|
|
let imageData = Data(rest[terminator + 2..<rest.endIndex]); return NSImage(data: imageData)
|
|
}
|
|
}
|
|
i += size
|
|
}
|
|
return nil
|
|
}
|
|
static func write(_ imageURL: URL, to audioURL: URL?) throws {
|
|
guard let audioURL, var data = try? Data(contentsOf: audioURL), data.count >= 10,
|
|
String(data: data.prefix(3), encoding: .ascii) == "ID3" else { throw AudioImportError.conversionFailed }
|
|
let tagEnd = min(data.count, 10 + data[6..<10].reduce(0) { ($0 << 7) | Int($1 & 0x7f) })
|
|
var frames = Data(); var i = 10
|
|
while i + 10 <= tagEnd {
|
|
guard let id = String(data: data[i..<i+4], encoding: .ascii) else { break }
|
|
let size = Int(data[i+4]) << 24 | Int(data[i+5]) << 16 | Int(data[i+6]) << 8 | Int(data[i+7]); guard size > 0, i + 10 + size <= tagEnd else { break }
|
|
if id != "APIC" { frames.append(data[i..<i+10+size]) }; i += 10 + size
|
|
}
|
|
let image = try Data(contentsOf: imageURL)
|
|
let mime = imageURL.pathExtension.lowercased() == "png" ? "image/png" : "image/jpeg"
|
|
let body = Data([3]) + Data(mime.utf8) + Data([0, 3, 0]) + image
|
|
var frame = Data("APIC".utf8); let n = body.count; frame.append(contentsOf: [UInt8((n >> 24)&255), UInt8((n >> 16)&255), UInt8((n >> 8)&255), UInt8(n&255), 0, 0]); frame.append(body); frames.append(frame)
|
|
let prefix = data.prefix(10); let oldAudioStart = tagEnd
|
|
let newTag = Data("ID3".utf8) + Data([3, 0, 0, UInt8((frames.count >> 21)&127), UInt8((frames.count >> 14)&127), UInt8((frames.count >> 7)&127), UInt8(frames.count&127)]) + frames
|
|
data = newTag + data[oldAudioStart...]
|
|
_ = prefix
|
|
try data.write(to: audioURL, options: .atomic)
|
|
}
|
|
}
|