diff --git a/Sources/TonuinoManager/UI/LibraryView.swift b/Sources/TonuinoManager/UI/LibraryView.swift index 6c59bad..cdd63dd 100644 --- a/Sources/TonuinoManager/UI/LibraryView.swift +++ b/Sources/TonuinoManager/UI/LibraryView.swift @@ -22,6 +22,8 @@ struct LibraryView: View { @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) { @@ -76,10 +78,12 @@ struct LibraryView: View { } } Divider() - PlayerBar(player: player, tracks: app.library.currentTracks).padding(.horizontal).padding(.vertical, 8) + 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 { @@ -307,53 +311,134 @@ struct LibraryView: View { private var trackPanel: some View { VStack(spacing: 0) { - HStack { - VStack(alignment: .leading) { - Text(app.library.currentFolder?.title ?? app.library.currentSystemFolder?.name ?? "Ordner auswählen").font(.title2.bold()) - 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() - if app.library.currentFolder != nil { - Menu("Auswahl", systemImage: "checkmark.circle") { - 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() + 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") } } } - if !app.library.selectedTrackIDs.isEmpty && app.library.currentFolder != nil { - Button("Löschen", systemImage: "trash", role: .destructive) { do { try app.library.deleteTracks(app.library.selectedTrackIDs) } catch { app.report(error) } } + 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) } - if !app.library.selectedTrackIDs.isEmpty && app.library.currentFolder != nil { - Button("Nach oben", systemImage: "arrow.up") { do { try app.library.moveSelectedTracks(by: -1) } catch { app.report(error) } } - Button("Nach unten", systemImage: "arrow.down") { do { try app.library.moveSelectedTracks(by: 1) } catch { app.report(error) } } + .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) } - if app.library.currentFolder != nil { - Button("MusicBrainz taggen…", systemImage: "tag") { - let folderTracks = app.library.currentTracks - taggingTracks = app.library.selectedTrackIDs.isEmpty ? folderTracks : folderTracks.filter { app.library.selectedTrackIDs.contains($0.id) } - showTagging = true - }.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") { - let folderTracks = app.library.currentTracks - taggingTracks = app.library.selectedTrackIDs.isEmpty ? folderTracks : folderTracks.filter { app.library.selectedTrackIDs.contains($0.id) } - showAcoustIDTagging = 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.currentFolder == nil || app.library.isLoading) - .help("Ordner \(String(format: "%02d", app.library.currentFolder?.number ?? 0)) auf eine NFC-Karte schreiben") + + Divider().padding(.vertical, 3) + + Button("MusicBrainz taggen…", systemImage: "tag") { + prepareTagging(usingAcoustID: false) } - Menu("Musik hinzufügen…", systemImage: "plus") { + .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 } - }.disabled(app.library.selectedFolder == nil || app.library.isLoading) - }.padding() - Divider() + } 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() @@ -370,25 +455,55 @@ struct LibraryView: View { 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(width: 38, alignment: .trailing) }.width(50) + 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 player.currentURL?.standardizedFileURL == track.url.standardizedFileURL { - Image(systemName: player.isPlaying ? "speaker.wave.2.fill" : "pause.circle.fill") - .foregroundStyle(.tint) - .frame(width: 15) - .help(player.isPlaying ? "Wird gerade abgespielt" : "Aktueller Track, pausiert") + 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("Interpret", value: \.artist) - TableColumn("Album", value: \.album) TableColumn("") { track in HStack(spacing: 8) { - Button { if let index = tracks.firstIndex(of: track) { player.play(tracks, startingAt: index) } } label: { Image(systemName: "play.fill") }.buttonStyle(.plain).help("Abspielen") + 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 } @@ -399,6 +514,8 @@ struct LibraryView: View { } label: { Image(systemName: "ellipsis") }.buttonStyle(.plain) } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .background(activeTrackTint(for: track)) }.width(70) } } else { @@ -408,6 +525,37 @@ struct LibraryView: View { } } + 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." @@ -964,12 +1112,13 @@ private struct EmptyState: View { 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: 36, height: 36).background(.quaternary).clipShape(RoundedRectangle(cornerRadius: 5)) + }.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 @@ -999,24 +1148,35 @@ private struct PlayerBar: View { let tracks: [MusicTrack] var body: some View { HStack(spacing: 12) { - CoverThumbnail(url: player.currentURL) + 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: 210, alignment: .leading) - Button { player.previous() } label: { Image(systemName: "backward.end.fill") }.buttonStyle(.plain).disabled(player.isLoading) + }.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(.plain) - .font(.title3) + .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).disabled(player.isLoading) + 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) diff --git a/Sources/TonuinoManager/UI/SettingsView.swift b/Sources/TonuinoManager/UI/SettingsView.swift index 11938e3..b6fc1a2 100644 --- a/Sources/TonuinoManager/UI/SettingsView.swift +++ b/Sources/TonuinoManager/UI/SettingsView.swift @@ -93,6 +93,38 @@ struct SettingsView: View { Text("Verwaltet TonUINO Musik-SD-Karten und programmiert RFID/NFC-Karten.").foregroundStyle(.secondary) Link("TonUINO-TNG Firmware", destination: URL(string: "https://github.com/tonuino/TonUINO-TNG")!) } + Section("Credits & Lizenzen") { + VStack(alignment: .leading, spacing: 6) { + Text("Funktionales Vorbild") + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + Link("Tonuino Manager von donschoof", destination: URL(string: "https://github.com/donschoof/tonuino-manager")!) + Text("Diese App ist eine native Swift-Neuimplementierung und orientiert sich an diesem ursprünglichen Projekt.") + .font(.caption) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Gebündelte Werkzeuge und Bibliotheken") + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + Link("FFmpeg · GPLv3-Build · Lizenzhinweise", destination: URL(string: "https://ffmpeg.org/legal.html")!) + Link("yt-dlp · Unlicense · Projekt und Lizenz", destination: URL(string: "https://github.com/yt-dlp/yt-dlp/blob/master/LICENSE")!) + Link("Deno · MIT · Projekt und Lizenz", destination: URL(string: "https://github.com/denoland/deno/blob/main/LICENSE.md")!) + Link("Chromaprint / fpcalc · LGPL-2.1 · Projekt und Lizenz", destination: URL(string: "https://github.com/acoustid/chromaprint/blob/master/LICENSE.md")!) + Link("Sparkle · MIT · Projekt und Lizenz", destination: URL(string: "https://github.com/sparkle-project/Sparkle/blob/2.x/LICENSE")!) + } + .font(.callout) + + VStack(alignment: .leading, spacing: 4) { + Text("Apple-Systemframeworks") + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + Text("SwiftUI, AppKit, Foundation, AVFoundation, Combine, CryptoKit, CryptoTokenKit, MediaPlayer, Security und Darwin.") + .font(.caption) + .foregroundStyle(.secondary) + } + } } .formStyle(.grouped) .padding(.top, 10)