Polish library player and add credits

This commit is contained in:
maddin committed 2026-10-31 16:57:32 +01:00
1 parent b44280480d
commit 80fe2e3867
2 files changed
+248 -56

No files matched your search

+216 -56
View File
@@ -22,6 +22,8 @@ struct LibraryView: View {
@State private var editingFolderNumber: Int? @State private var editingFolderNumber: Int?
@State private var folderToRename: MusicFolder? @State private var folderToRename: MusicFolder?
@State private var taggingTracks: [MusicTrack] = [] @State private var taggingTracks: [MusicTrack] = []
@State private var isEditingFolderName = false
@State private var editedFolderName = ""
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
@@ -76,10 +78,12 @@ struct LibraryView: View {
} }
} }
Divider() 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 .onChange(of: app.library.selectedFolder) { newValue in
if newValue != nil { app.library.selectedSystemFolderName = nil } 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 .fileImporter(isPresented: $showImporter, allowedContentTypes: [.audio], allowsMultipleSelection: true) { result in
Task { Task {
@@ -307,53 +311,134 @@ struct LibraryView: View {
private var trackPanel: some View { private var trackPanel: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack { folderHeader.padding(.horizontal, 16).padding(.vertical, 12)
VStack(alignment: .leading) { Divider()
Text(app.library.currentFolder?.title ?? app.library.currentSystemFolder?.name ?? "Ordner auswählen").font(.title2.bold()) HStack(spacing: 0) {
Text(app.library.currentFolder != nil ? "\(app.library.currentFolder?.tracks.count ?? 0) von maximal 255 Tracks" : "Systemordner · nur Anzeige und Wiedergabe") trackContent
.font(.caption).foregroundStyle(.secondary) .frame(maxWidth: .infinity, maxHeight: .infinity)
} Divider()
Spacer() trackActions
if app.library.currentFolder != nil { .frame(width: 200)
Menu("Auswahl", systemImage: "checkmark.circle") { .frame(maxHeight: .infinity, alignment: .topLeading)
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() 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 { Text(app.library.currentFolder != nil ? "\(app.library.currentFolder?.tracks.count ?? 0) von maximal 255 Tracks" : "Systemordner · nur Anzeige und Wiedergabe")
Button("Löschen", systemImage: "trash", role: .destructive) { do { try app.library.deleteTracks(app.library.selectedTrackIDs) } catch { app.report(error) } } .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 { .buttonStyle(.bordered)
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) } } 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") { Divider().padding(.vertical, 3)
let folderTracks = app.library.currentTracks
taggingTracks = app.library.selectedTrackIDs.isEmpty ? folderTracks : folderTracks.filter { app.library.selectedTrackIDs.contains($0.id) } Button("MusicBrainz taggen…", systemImage: "tag") {
showTagging = true 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") {
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")
} }
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("Audiodateien…", systemImage: "music.note") { showImporter = true }
Button("YouTube-Video…", systemImage: "play.rectangle") { showYouTubeImport = true } Button("YouTube-Video…", systemImage: "play.rectangle") { showYouTubeImport = true }
}.disabled(app.library.selectedFolder == nil || app.library.isLoading) } label: {
}.padding() Label("Musik hinzufügen…", systemImage: "plus")
Divider() .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 { if app.library.isLoading {
VStack(spacing: 12) { VStack(spacing: 12) {
ProgressView() ProgressView()
@@ -370,25 +455,55 @@ struct LibraryView: View {
let tracks = app.library.currentTracks let tracks = app.library.currentTracks
if !tracks.isEmpty { if !tracks.isEmpty {
Table(tracks, selection: $app.library.selectedTrackIDs) { 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 TableColumn("Titel") { track in
HStack(spacing: 6) { HStack(spacing: 6) {
if player.currentURL?.standardizedFileURL == track.url.standardizedFileURL { if isActiveTrack(track) {
Image(systemName: player.isPlaying ? "speaker.wave.2.fill" : "pause.circle.fill") if player.isLoading {
.foregroundStyle(.tint) ProgressView().controlSize(.small).frame(width: 15)
.frame(width: 15) } else {
.help(player.isPlaying ? "Wird gerade abgespielt" : "Aktueller Track, pausiert") 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 { } else {
Color.clear.frame(width: 15, height: 12) Color.clear.frame(width: 15, height: 12)
} }
Text(track.displayTitle).lineLimit(1) 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 TableColumn("") { track in
HStack(spacing: 8) { 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 { if app.library.currentFolder != nil {
Menu { Menu {
Button("Metadaten bearbeiten/anlegen…") { editingTrack = track; showMetadata = true } Button("Metadaten bearbeiten/anlegen…") { editingTrack = track; showMetadata = true }
@@ -399,6 +514,8 @@ struct LibraryView: View {
} label: { Image(systemName: "ellipsis") }.buttonStyle(.plain) } label: { Image(systemName: "ellipsis") }.buttonStyle(.plain)
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.background(activeTrackTint(for: track))
}.width(70) }.width(70)
} }
} else { } 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() { private func chooseCard() {
let panel = NSOpenPanel(); panel.canChooseFiles = false; panel.canChooseDirectories = true; panel.allowsMultipleSelection = false let panel = NSOpenPanel(); panel.canChooseFiles = false; panel.canChooseDirectories = true; panel.allowsMultipleSelection = false
panel.message = "Wähle das Volume der TonUINO-SD-Karte aus." panel.message = "Wähle das Volume der TonUINO-SD-Karte aus."
@@ -964,12 +1112,13 @@ private struct EmptyState: View {
private struct CoverThumbnail: View { private struct CoverThumbnail: View {
var url: URL? var url: URL?
var size: CGFloat = 36
@State private var image: NSImage? @State private var image: NSImage?
var body: some View { var body: some View {
Group { Group {
if let image { Image(nsImage: image).resizable().scaledToFill() } if let image { Image(nsImage: image).resizable().scaledToFill() }
else { Image(systemName: "music.note").resizable().scaledToFit().padding(8).foregroundStyle(.secondary) } 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) { .task(id: url) {
guard let url else { image = nil; return } guard let url else { image = nil; return }
image = await CoverImageCache.shared.image(for: url)?.image image = await CoverImageCache.shared.image(for: url)?.image
@@ -999,24 +1148,35 @@ private struct PlayerBar: View {
let tracks: [MusicTrack] let tracks: [MusicTrack]
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
CoverThumbnail(url: player.currentURL) CoverThumbnail(url: player.currentURL, size: 52)
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
Text(player.title.isEmpty ? "Kein Track ausgewählt" : player.title).lineLimit(1) Text(player.title.isEmpty ? "Kein Track ausgewählt" : player.title).lineLimit(1)
let showsStatus = player.isLoading || player.statusMessage.hasPrefix("Wiedergabe fehlgeschlagen") let showsStatus = player.isLoading || player.statusMessage.hasPrefix("Wiedergabe fehlgeschlagen")
Text(showsStatus || player.artist.isEmpty ? player.statusMessage : player.artist) Text(showsStatus || player.artist.isEmpty ? player.statusMessage : player.artist)
.font(.caption).foregroundStyle(player.statusMessage.hasPrefix("Wiedergabe fehlgeschlagen") ? .red : .secondary).lineLimit(1) .font(.caption).foregroundStyle(player.statusMessage.hasPrefix("Wiedergabe fehlgeschlagen") ? .red : .secondary).lineLimit(1)
}.frame(width: 210, alignment: .leading) }.frame(width: 230, alignment: .leading)
Button { player.previous() } label: { Image(systemName: "backward.end.fill") }.buttonStyle(.plain).disabled(player.isLoading) Button { player.previous() } label: { Image(systemName: "backward.end.fill") }
.buttonStyle(.plain)
.font(.title2)
.frame(width: 42, height: 44)
.disabled(player.isLoading)
Button { toggleOrPlayFolder() } label: { Button { toggleOrPlayFolder() } label: {
if player.isLoading { ProgressView().controlSize(.small) } if player.isLoading { ProgressView().controlSize(.small) }
else { Image(systemName: player.isPlaying ? "pause.fill" : "play.fill") } else { Image(systemName: player.isPlaying ? "pause.fill" : "play.fill") }
} }
.buttonStyle(.plain) .buttonStyle(.borderedProminent)
.font(.title3) .controlSize(.large)
.font(.title2.weight(.semibold))
.frame(width: 48, height: 44)
.clipShape(Circle())
.keyboardShortcut(.space, modifiers: []) .keyboardShortcut(.space, modifiers: [])
.help(player.isCurrentTrack(in: tracks) ? "Wiedergabe/Pause (Leertaste)" : "Ordner abspielen (Leertaste)") .help(player.isCurrentTrack(in: tracks) ? "Wiedergabe/Pause (Leertaste)" : "Ordner abspielen (Leertaste)")
.disabled(player.isLoading || tracks.isEmpty) .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) 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) 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) Text(time(player.duration)).font(.caption.monospacedDigit()).foregroundStyle(.secondary)
@@ -93,6 +93,38 @@ struct SettingsView: View {
Text("Verwaltet TonUINO Musik-SD-Karten und programmiert RFID/NFC-Karten.").foregroundStyle(.secondary) 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")!) 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) .formStyle(.grouped)
.padding(.top, 10) .padding(.top, 10)