Polish library player and add credits
This commit is contained in:
1 parent
b44280480d
commit
80fe2e3867
2 files changed
+248
-56
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user