Add Tonuino Manager website and Sparkle updates
This commit is contained in:
commit
1cac427ff2
44 files changed
+5861
No files matched your search
@@ -0,0 +1,224 @@
|
||||
import SwiftUI
|
||||
|
||||
private struct AcoustIDTrackResult: Identifiable {
|
||||
let track: MusicTrack
|
||||
var matches: [AcoustIDMatch] = []
|
||||
var selectedMatchID = ""
|
||||
var selectedReleaseID = ""
|
||||
var status = "Bereit"
|
||||
var id: String { track.id }
|
||||
var selectedMatch: AcoustIDMatch? { matches.first { $0.id == selectedMatchID } }
|
||||
}
|
||||
|
||||
struct AcoustIDTaggingView: View {
|
||||
let tracks: [MusicTrack]
|
||||
let onComplete: () -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var rows: [AcoustIDTrackResult]
|
||||
@State private var isIdentifying = false
|
||||
@State private var isWriting = false
|
||||
@State private var progressText = ""
|
||||
@State private var message: String?
|
||||
|
||||
init(tracks: [MusicTrack], onComplete: @escaping () -> Void) {
|
||||
self.tracks = tracks
|
||||
self.onComplete = onComplete
|
||||
_rows = State(initialValue: tracks.map { AcoustIDTrackResult(track: $0) })
|
||||
}
|
||||
|
||||
private var selectedCount: Int {
|
||||
rows.filter { $0.selectedMatch != nil }.count
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Musik mit AcoustID identifizieren").font(.title2.bold())
|
||||
Text("\(tracks.count) Datei\(tracks.count == 1 ? "" : "en") · Treffer werden vor dem Schreiben geprüft")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button { dismiss() } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary).font(.title2) }.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if isIdentifying || isWriting {
|
||||
ProgressView(progressText).frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
if let message { Text(message).font(.callout).foregroundStyle(.secondary) }
|
||||
|
||||
ScrollView {
|
||||
LazyVStack(alignment: .leading, spacing: 10) {
|
||||
ForEach(rows.indices, id: \.self) { index in
|
||||
trackResult(at: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 240)
|
||||
|
||||
HStack {
|
||||
Text("Fingerabdrücke werden mit Chromaprint berechnet. AcoustID liefert mögliche MusicBrainz-Aufnahmen.")
|
||||
.font(.caption).foregroundStyle(.secondary).frame(maxWidth: .infinity, alignment: .leading)
|
||||
Button("Schließen") { dismiss() }.keyboardShortcut(.cancelAction)
|
||||
Button("Fingerabdrücke suchen", systemImage: "waveform") { identifyTracks() }
|
||||
.disabled(isIdentifying || isWriting || tracks.isEmpty || AcoustIDCredentials.loadClientKey() == nil)
|
||||
Button("Ausgewählte Treffer schreiben", systemImage: "tag") { writeTags() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(isIdentifying || isWriting || selectedCount == 0)
|
||||
}
|
||||
}
|
||||
.padding(22)
|
||||
.frame(width: 920, height: 680)
|
||||
.onAppear {
|
||||
if AcoustIDCredentials.loadClientKey() == nil {
|
||||
message = "Trage einen AcoustID-Anwendungsschlüssel in den Einstellungen ein, bevor du die Suche startest."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func trackResult(at index: Int) -> some View {
|
||||
let row = rows[index]
|
||||
VStack(alignment: .leading, spacing: 7) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(row.track.url.lastPathComponent).font(.subheadline.weight(.medium)).lineLimit(1)
|
||||
Text(row.status).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if !row.matches.isEmpty {
|
||||
Picker("Aufnahme", selection: matchBinding(at: index)) {
|
||||
Text("Nicht taggen").tag("")
|
||||
ForEach(row.matches) { match in
|
||||
Text("\(Int((match.score * 100).rounded()))% · \(match.artist.isEmpty ? "Unbekannter Interpret" : match.artist) — \(match.title)")
|
||||
.tag(match.id)
|
||||
}
|
||||
}
|
||||
.frame(width: 470)
|
||||
}
|
||||
}
|
||||
if let match = row.selectedMatch {
|
||||
HStack {
|
||||
Text(String(format: "Übereinstimmung %.0f%%", match.score * 100))
|
||||
.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
||||
if !match.releases.isEmpty {
|
||||
Picker("Veröffentlichung", selection: releaseBinding(at: index)) {
|
||||
ForEach(match.releases) { release in
|
||||
Text("\(release.title)\(release.year.isEmpty ? "" : " · \(release.year)")")
|
||||
.tag(release.id)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
} else {
|
||||
Text("Kein verknüpftes Album").font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(11)
|
||||
.background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private func matchBinding(at index: Int) -> Binding<String> {
|
||||
Binding(
|
||||
get: { rows[index].selectedMatchID },
|
||||
set: { value in
|
||||
rows[index].selectedMatchID = value
|
||||
rows[index].selectedReleaseID = rows[index].selectedMatch?.releases.first?.id ?? ""
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func releaseBinding(at index: Int) -> Binding<String> {
|
||||
Binding(get: { rows[index].selectedReleaseID }, set: { rows[index].selectedReleaseID = $0 })
|
||||
}
|
||||
|
||||
private func identifyTracks() {
|
||||
guard let clientKey = AcoustIDCredentials.loadClientKey() else {
|
||||
message = "Trage einen AcoustID-Anwendungsschlüssel in den Einstellungen ein."
|
||||
return
|
||||
}
|
||||
isIdentifying = true
|
||||
message = nil
|
||||
Task {
|
||||
for index in rows.indices {
|
||||
progressText = "Analysiere Datei \(index + 1) von \(rows.count)…"
|
||||
rows[index].status = "Fingerabdruck und Datenbanksuche laufen…"
|
||||
do {
|
||||
let matches = try await AcoustIDService.identify(rows[index].track.url, clientKey: clientKey)
|
||||
rows[index].matches = matches
|
||||
rows[index].selectedMatchID = matches.first?.id ?? ""
|
||||
rows[index].selectedReleaseID = matches.first?.releases.first?.id ?? ""
|
||||
rows[index].status = matches.isEmpty ? "Kein AcoustID-Treffer" : "\(matches.count) Treffer · besten Treffer vorausgewählt"
|
||||
} catch {
|
||||
rows[index].matches = []
|
||||
rows[index].status = "Fehler: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
isIdentifying = false
|
||||
progressText = ""
|
||||
let found = rows.filter { !$0.matches.isEmpty }.count
|
||||
message = "Suche abgeschlossen: Treffer für \(found) von \(rows.count) Dateien. Prüfe die Zuordnung und wähle, was geschrieben werden soll."
|
||||
}
|
||||
}
|
||||
|
||||
private func writeTags() {
|
||||
isWriting = true
|
||||
message = nil
|
||||
Task {
|
||||
var written = 0
|
||||
var failures: [String] = []
|
||||
for index in rows.indices {
|
||||
guard let match = rows[index].selectedMatch else { continue }
|
||||
let track = rows[index].track
|
||||
progressText = "Schreibe Datei \(index + 1) von \(rows.count)…"
|
||||
do {
|
||||
var metadata = TrackMetadata()
|
||||
metadata.title = match.title
|
||||
metadata.artist = match.artist
|
||||
metadata.acoustID = match.acoustID
|
||||
metadata.musicBrainzRecordingID = match.recordingID
|
||||
metadata.musicBrainzReleaseID = ""
|
||||
|
||||
if let releaseOption = match.releases.first(where: { $0.id == rows[index].selectedReleaseID }) {
|
||||
let release = try await MusicBrainzService.loadRelease(id: releaseOption.id)
|
||||
metadata.album = release.title
|
||||
metadata.albumArtist = release.artist.isEmpty ? releaseOption.artist : release.artist
|
||||
metadata.year = release.year.isEmpty ? releaseOption.year : release.year
|
||||
metadata.genre = release.genre
|
||||
metadata.musicBrainzReleaseID = release.id
|
||||
if let recording = release.tracks.first(where: { $0.recording?.id == match.recordingID }) {
|
||||
metadata.title = recording.title
|
||||
metadata.artist = recording.artist.isEmpty ? (release.artist.isEmpty ? match.artist : release.artist) : recording.artist
|
||||
metadata.trackNumber = recording.displayNumber
|
||||
metadata.discNumber = recording.discNumber
|
||||
}
|
||||
} else {
|
||||
metadata.album = ""
|
||||
metadata.albumArtist = ""
|
||||
metadata.trackNumber = ""
|
||||
metadata.discNumber = ""
|
||||
metadata.year = ""
|
||||
metadata.genre = ""
|
||||
}
|
||||
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
try ID3Metadata.write(metadata, to: track.url)
|
||||
try ID3Metadata.verify(metadata, at: track.url)
|
||||
}.value
|
||||
rows[index].status = "Tags geschrieben und überprüft"
|
||||
written += 1
|
||||
} catch {
|
||||
rows[index].status = "Fehler beim Schreiben: \(error.localizedDescription)"
|
||||
failures.append(track.url.lastPathComponent)
|
||||
}
|
||||
}
|
||||
isWriting = false
|
||||
progressText = ""
|
||||
onComplete()
|
||||
message = failures.isEmpty
|
||||
? "\(written) Datei\(written == 1 ? " wurde" : "en wurden") getaggt und überprüft."
|
||||
: "\(written) Dateien getaggt. Fehler bei: \(failures.joined(separator: ", "))."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct CardsView: View {
|
||||
@EnvironmentObject private var app: AppModel
|
||||
@State private var route = CardRoute.tonuino
|
||||
@State private var folder = 1
|
||||
@State private var mode: PlaybackMode = .album
|
||||
@State private var category: CardEditorCategory = .playback
|
||||
@State private var modifier: TonuinoModifier = .sleepTimer
|
||||
@State private var special = 1
|
||||
@State private var special2 = 1
|
||||
@State private var sleepTimerMinutes = 30
|
||||
@State private var stopAfterCurrentTrack = false
|
||||
@State private var danceDifficulty = 1
|
||||
@State private var busy = false
|
||||
@State private var tonuinoWritePending = false
|
||||
@State private var lastRead: TonuinoCard?
|
||||
@State private var lastUID = ""
|
||||
@AppStorage("reader.route") private var defaultRoute = "tonuino"
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text("NFC-Karten").font(.largeTitle.bold())
|
||||
Text("Ordnerkarten, Wiedergabemodi und Admin-Karten programmieren oder auslesen.").foregroundStyle(.secondary)
|
||||
}.padding()
|
||||
Divider()
|
||||
Form {
|
||||
Section("Programmierweg") {
|
||||
Picker("Kartenleser", selection: $route) { ForEach(CardRoute.allCases) { Text($0.title).tag($0) } }
|
||||
.pickerStyle(.segmented)
|
||||
if route == .tonuino { serialControls }
|
||||
else { readerControls }
|
||||
}
|
||||
Section("Karteninhalt") {
|
||||
Picker("Kategorie", selection: $category) {
|
||||
ForEach(CardEditorCategory.allCases) { Text($0.title).tag($0) }
|
||||
}
|
||||
if category == .playback {
|
||||
Picker("Kartenart", selection: $mode) { ForEach(PlaybackMode.allCases) { Text($0.title).tag($0) } }
|
||||
if mode.needsFolder && mode != .admin {
|
||||
if let library = app.library.folders.first(where: { $0.number == folder }) {
|
||||
Picker("Ordner", selection: $folder) {
|
||||
ForEach(app.library.folders) { item in Text("\(String(format: "%02d", item.number)) — \(item.title)").tag(item.number) }
|
||||
}
|
||||
.onAppear { if !app.library.folders.contains(where: { $0.number == folder }) { folder = library.number } }
|
||||
} else {
|
||||
Stepper("Ordner \(String(format: "%02d", folder))", value: $folder, in: 1...99)
|
||||
}
|
||||
}
|
||||
if mode.needsSpecial && mode != .quiz { Stepper(specialLabel, value: $special, in: mode == .audiobookSingle ? 0...29 : 1...255) }
|
||||
if mode.needsSpecial2 {
|
||||
if mode == .quiz {
|
||||
Picker("Fragen", selection: $special) { Text("Alle").tag(0); Text("2 Fragen").tag(2); Text("4 Fragen").tag(4) }
|
||||
Picker("Antworten pro Frage", selection: $special2) { Text("2").tag(0); Text("3").tag(1) }
|
||||
} else { Stepper("Endtrack: \(special2)", value: $special2, in: max(1, special)...255) }
|
||||
}
|
||||
Text("\(mode.title)" + (mode.needsFolder ? " · Ordner \(String(format: "%02d", folder))" : ""))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
Picker("Sonderkarte", selection: $modifier) {
|
||||
ForEach(TonuinoModifier.allCases) { Text($0.title).tag($0) }
|
||||
}
|
||||
switch modifier {
|
||||
case .sleepTimer:
|
||||
Stepper("Laufzeit: \(sleepTimerMinutes) Minuten", value: $sleepTimerMinutes,
|
||||
in: 1...(stopAfterCurrentTrack ? 127 : 255))
|
||||
Toggle("Nach dem aktuellen Track stoppen", isOn: $stopAfterCurrentTrack)
|
||||
case .freezeDance, .fireWaterAir:
|
||||
Picker("Pausenstufe", selection: $danceDifficulty) {
|
||||
Text("1").tag(0); Text("2").tag(1); Text("3").tag(2)
|
||||
}
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
if let note = modifier.firmwareNote {
|
||||
Label(note, systemImage: "info.circle")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Text(modifier.title + " · Ordnernummer 00")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Section("Karte") {
|
||||
HStack {
|
||||
Label(lastUID.isEmpty ? "Keine Karte gelesen" : "UID \(lastUID)", systemImage: "wave.3.right")
|
||||
Spacer()
|
||||
if !lastUID.isEmpty {
|
||||
Text(lastRead.map { $0.displayTitle + ($0.folder > 0 ? " · Ordner \(String(format: "%02d", $0.folder))" : "") } ?? "Unprogrammierte Karte")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
HStack {
|
||||
Button("Karte lesen", systemImage: "doc.text.magnifyingglass") { Task { await readCard() } }.disabled(busy || route != .acr122u)
|
||||
Button("Programmieren", systemImage: "wave.3.right", role: .none) { Task { await writeCard() } }
|
||||
.buttonStyle(.borderedProminent).disabled(busy || tonuinoWritePending || !isRouteReady || (category == .modifier && route == .tonuino))
|
||||
Button("Dump exportieren…", systemImage: "square.and.arrow.up") { exportDump() }
|
||||
.disabled(busy || tonuinoWritePending)
|
||||
if tonuinoWritePending {
|
||||
Button("Abbrechen", role: .cancel) {
|
||||
do { try app.cards.cancelWrite(); tonuinoWritePending = false }
|
||||
catch { app.report(error) }
|
||||
}
|
||||
Button("Fertig") {
|
||||
do { try app.cards.cancelWrite(); tonuinoWritePending = false }
|
||||
catch { app.report(error) }
|
||||
}
|
||||
}
|
||||
if busy && route == .tonuino {
|
||||
Button("Abbrechen", role: .cancel) { do { try app.cards.cancelWrite() } catch { app.report(error) } }
|
||||
}
|
||||
if route == .acr122u {
|
||||
Button("Karte löschen", systemImage: "eraser", role: .destructive) { Task { await eraseCard() } }.disabled(busy || !isRouteReady)
|
||||
}
|
||||
if busy { ProgressView().controlSize(.small); Text("TonUINO-Menü wird vorbereitet…").foregroundStyle(.secondary) }
|
||||
else if tonuinoWritePending { Label("Karte auf den TonUINO-Leser legen", systemImage: "wave.3.right").foregroundStyle(.secondary) }
|
||||
}
|
||||
if category == .modifier && route == .tonuino {
|
||||
Label("Der serielle TonUINO-Menüassistent kann diese Sonderkarte nicht anlegen. Nutze einen ACR122U-Reader oder exportiere einen Dump.", systemImage: "info.circle")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}.formStyle(.grouped).scrollContentBackground(.hidden)
|
||||
Spacer(minLength: 0)
|
||||
Text(route == .tonuino
|
||||
? "TonUINO muss im Leerlauf sein. Für USB-Steuerung muss SerialInputAsCommand in der Firmware aktiviert sein. Folge den Audioansagen und lege die Karte auf, wenn TonUINO dazu auffordert."
|
||||
: "ACR122U unterstützt MIFARE Mini, Classic 1K/4K und Ultralight/NTAG21x.")
|
||||
.font(.caption).foregroundStyle(.secondary).padding()
|
||||
}
|
||||
.onAppear {
|
||||
route = defaultRoute == "acr122u" ? .acr122u : .tonuino
|
||||
app.cards.refreshReaders(); app.cards.refreshSerialPorts()
|
||||
}
|
||||
.onChange(of: special) { newValue in
|
||||
if mode != .quiz, mode.needsSpecial2, special2 < newValue { special2 = newValue }
|
||||
}
|
||||
.onChange(of: stopAfterCurrentTrack) { enabled in
|
||||
if enabled { sleepTimerMinutes = min(sleepTimerMinutes, 127) }
|
||||
}
|
||||
.task(id: route) {
|
||||
guard route == .acr122u else { return }
|
||||
while !Task.isCancelled {
|
||||
if !busy {
|
||||
do { let (uid, card) = try await app.cards.readViaReader(); lastUID = uid; lastRead = card }
|
||||
catch { lastUID = ""; lastRead = nil }
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 1_500_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isRouteReady: Bool { route == .tonuino ? app.cards.serialConnected : !app.cards.selectedReader.isEmpty }
|
||||
private var specialLabel: String { mode == .single ? "Tracknummer: \(special)" : mode == .audiobookSingle ? "Zusatztracks: \(special)" : "Starttrack: \(special)" }
|
||||
|
||||
private var serialControls: some View {
|
||||
HStack {
|
||||
Picker("Port", selection: $app.cards.selectedSerialPort) { ForEach(app.cards.serialPorts, id: \.self) { Text($0).tag($0) } }
|
||||
Button("Aktualisieren", systemImage: "arrow.clockwise") { app.cards.refreshSerialPorts() }
|
||||
if app.cards.serialConnected {
|
||||
Label(app.cards.firmwareVersion.map { "Verbunden · \($0)" } ?? "Verbunden · Firmwareversion nicht erkannt", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Button("Trennen") { app.cards.disconnectSerial() }
|
||||
} else {
|
||||
Button("Verbinden") { Task { do { try await app.cards.connectSerial() } catch { app.report(error) } } }.disabled(app.cards.selectedSerialPort.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var readerControls: some View {
|
||||
HStack {
|
||||
Picker("Reader", selection: $app.cards.selectedReader) { ForEach(app.cards.readerNames, id: \.self) { Text($0).tag($0) } }
|
||||
Button("Reader suchen", systemImage: "arrow.clockwise") { app.cards.refreshReaders() }
|
||||
Text(app.cards.readerNames.isEmpty ? "Kein Smartcard-Reader gefunden" : "Reader bereit")
|
||||
.foregroundStyle(app.cards.readerNames.isEmpty ? Color.secondary : Color.green)
|
||||
}
|
||||
}
|
||||
|
||||
private func readCard() async {
|
||||
busy = true; defer { busy = false }
|
||||
do {
|
||||
let (uid, card) = try await app.cards.readViaReader()
|
||||
lastUID = uid
|
||||
lastRead = card
|
||||
if let card {
|
||||
folder = Int(card.folder)
|
||||
mode = card.mode
|
||||
special = Int(card.special)
|
||||
special2 = Int(card.special2)
|
||||
if let cardModifier = card.modifier {
|
||||
category = .modifier
|
||||
modifier = cardModifier
|
||||
if cardModifier == .sleepTimer {
|
||||
stopAfterCurrentTrack = card.special > 128
|
||||
sleepTimerMinutes = Int(card.special) - (stopAfterCurrentTrack ? 128 : 0)
|
||||
} else if cardModifier == .freezeDance || cardModifier == .fireWaterAir {
|
||||
danceDifficulty = min(Int(card.special), 2)
|
||||
}
|
||||
} else {
|
||||
category = .playback
|
||||
}
|
||||
}
|
||||
app.status = card == nil ? "Unprogrammierte Karte" : "Karte ausgelesen"
|
||||
}
|
||||
catch { app.report(error) }
|
||||
}
|
||||
private func writeCard() async {
|
||||
busy = true; defer { busy = false }
|
||||
do {
|
||||
let card = try makeCard()
|
||||
if route == .tonuino {
|
||||
try await app.cards.writeViaTonuino(card)
|
||||
tonuinoWritePending = true
|
||||
app.status = "TonUINO-Schreibassistent gestartet; folge den Audioansagen."
|
||||
} else {
|
||||
try await app.cards.writeViaReader(card)
|
||||
lastRead = card
|
||||
app.status = "Karte erfolgreich programmiert"
|
||||
}
|
||||
} catch { app.report(error) }
|
||||
}
|
||||
private func eraseCard() async {
|
||||
busy = true; defer { busy = false }
|
||||
do { try await app.cards.eraseViaReader(); lastRead = nil; app.status = "Karte gelöscht" } catch { app.report(error) }
|
||||
}
|
||||
private func makeCard() throws -> TonuinoCard {
|
||||
if category == .modifier {
|
||||
let parameter: UInt8
|
||||
switch modifier {
|
||||
case .sleepTimer:
|
||||
parameter = UInt8(sleepTimerMinutes + (stopAfterCurrentTrack ? 128 : 0))
|
||||
case .freezeDance, .fireWaterAir:
|
||||
parameter = UInt8(danceDifficulty)
|
||||
default:
|
||||
parameter = 0
|
||||
}
|
||||
let value = TonuinoCard.makeModifier(modifier, special: parameter)
|
||||
try value.validate()
|
||||
return value
|
||||
}
|
||||
let value = TonuinoCard(folder: mode == .admin ? 0 : UInt8(folder), mode: mode, special: UInt8(special), special2: UInt8(special2))
|
||||
try value.validate()
|
||||
return value
|
||||
}
|
||||
|
||||
private func exportDump() {
|
||||
do {
|
||||
let card = try makeCard()
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = dumpFilename(for: card)
|
||||
panel.message = "Speichere einen MIFARE-Classic-1K-Dump für den Pixl.js-Kartenemulator."
|
||||
panel.prompt = "Speichern"
|
||||
panel.canCreateDirectories = true
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||
try MifareClassicDump.make1K(for: card).write(to: url, options: .atomic)
|
||||
app.status = "Kartendump exportiert: \(card.displayTitle)"
|
||||
} catch { app.report(error) }
|
||||
}
|
||||
|
||||
private func dumpFilename(for card: TonuinoCard) -> String {
|
||||
let title = card.displayTitle
|
||||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
.lowercased()
|
||||
.map { $0.isLetter || $0.isNumber ? $0 : "-" }
|
||||
.split(separator: "-")
|
||||
.joined(separator: "-")
|
||||
if card.modifier != nil { return "Tonuino-Sonderkarte-\(title).bin" }
|
||||
return "Tonuino-\(title)-Ordner-\(String(format: "%02d", card.folder)).bin"
|
||||
}
|
||||
}
|
||||
|
||||
private enum CardEditorCategory: String, CaseIterable, Identifiable {
|
||||
case playback, modifier
|
||||
var id: String { rawValue }
|
||||
var title: String { self == .playback ? "Wiedergabekarte" : "Sonderkarte" }
|
||||
}
|
||||
|
||||
enum CardRoute: String, CaseIterable, Identifiable {
|
||||
case tonuino, acr122u
|
||||
var id: String { rawValue }
|
||||
var title: String { self == .tonuino ? "Über TonUINO" : "ACR122U Reader" }
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct FolderCardWriterView: View {
|
||||
let folder: MusicFolder
|
||||
@EnvironmentObject private var app: AppModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@AppStorage("reader.route") private var savedRoute = "tonuino"
|
||||
@State private var route: CardRoute = .tonuino
|
||||
@State private var mode: PlaybackMode = .album
|
||||
@State private var special = 1
|
||||
@State private var special2 = 1
|
||||
@State private var isWriting = false
|
||||
@State private var tonuinoWritePending = false
|
||||
@State private var resultMessage: String?
|
||||
@State private var writeSucceeded = false
|
||||
|
||||
private var isRouteReady: Bool {
|
||||
route == .tonuino ? app.cards.serialConnected : !app.cards.selectedReader.isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("NFC-Karte programmieren").font(.title2.bold())
|
||||
Text("\(folder.title) · Ordner \(String(format: "%02d", folder.number)) · \(folder.tracks.count) Tracks")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Form {
|
||||
Section("Wiedergabe") {
|
||||
Picker("Modus", selection: $mode) {
|
||||
ForEach(PlaybackMode.allCases) { item in Text(item.title).tag(item) }
|
||||
}
|
||||
if mode.needsSpecial && mode != .quiz {
|
||||
Stepper(specialLabel, value: $special, in: mode == .audiobookSingle ? 0...29 : 1...255)
|
||||
}
|
||||
if mode.needsSpecial2 {
|
||||
if mode == .quiz {
|
||||
Picker("Fragen", selection: $special) {
|
||||
Text("Alle").tag(0); Text("2 Fragen").tag(2); Text("4 Fragen").tag(4)
|
||||
}
|
||||
Picker("Antworten pro Frage", selection: $special2) {
|
||||
Text("2").tag(0); Text("3").tag(1)
|
||||
}
|
||||
} else {
|
||||
Stepper("Endtrack: \(special2)", value: $special2, in: max(1, special)...255)
|
||||
}
|
||||
}
|
||||
Text(mode.needsFolder && mode != .admin
|
||||
? "Die Karte verweist auf Ordner \(String(format: "%02d", folder.number))."
|
||||
: "Dieser Modus verwendet keine Ordnernummer.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Kartenleser") {
|
||||
Picker("Schreibweg", selection: $route) {
|
||||
ForEach(CardRoute.allCases) { Text($0.title).tag($0) }
|
||||
}.pickerStyle(.segmented)
|
||||
|
||||
if route == .tonuino {
|
||||
HStack {
|
||||
Picker("Serieller Port", selection: $app.cards.selectedSerialPort) {
|
||||
ForEach(app.cards.serialPorts, id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
Button("Suchen", systemImage: "arrow.clockwise") { app.cards.refreshSerialPorts() }
|
||||
if app.cards.serialConnected {
|
||||
Label(app.cards.firmwareVersion.map { "Verbunden · \($0)" } ?? "Verbunden · Firmwareversion nicht erkannt", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Button("Trennen") { app.cards.disconnectSerial() }
|
||||
} else {
|
||||
Button("Verbinden") { Task { await connectSerial() } }
|
||||
.disabled(app.cards.selectedSerialPort.isEmpty || isWriting)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
Picker("Reader", selection: $app.cards.selectedReader) {
|
||||
ForEach(app.cards.readerNames, id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
Button("Suchen", systemImage: "arrow.clockwise") { app.cards.refreshReaders() }
|
||||
}
|
||||
Text(app.cards.readerNames.isEmpty ? "Kein PC/SC-Reader gefunden." : "Karte auf den ausgewählten Reader legen, wenn die App wartet.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.scrollContentBackground(.hidden)
|
||||
|
||||
if let resultMessage {
|
||||
Label(resultMessage, systemImage: writeSucceeded ? "checkmark.circle.fill" : (tonuinoWritePending ? "info.circle.fill" : "exclamationmark.triangle.fill"))
|
||||
.foregroundStyle(writeSucceeded ? Color.green : (tonuinoWritePending ? Color.secondary : Color.red))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Text(route == .tonuino
|
||||
? "TonUINO muss im Leerlauf sein. Lege die Karte auf, sobald die Firmware dazu auffordert."
|
||||
: "Lege eine unterstützte MIFARE- oder NTAG-Karte auf den PC/SC-Reader.")
|
||||
.font(.caption).foregroundStyle(.secondary).frame(maxWidth: .infinity, alignment: .leading)
|
||||
Button("Schließen") {
|
||||
if tonuinoWritePending { try? app.cards.cancelWrite() }
|
||||
dismiss()
|
||||
}.keyboardShortcut(.cancelAction)
|
||||
if tonuinoWritePending {
|
||||
Button("Abbrechen", role: .cancel) {
|
||||
do { try app.cards.cancelWrite(); tonuinoWritePending = false; resultMessage = "Schreibvorgang abgebrochen." }
|
||||
catch { resultMessage = error.localizedDescription }
|
||||
}
|
||||
Button("Fertig") {
|
||||
do { try app.cards.cancelWrite(); tonuinoWritePending = false; dismiss() }
|
||||
catch { resultMessage = error.localizedDescription }
|
||||
}
|
||||
}
|
||||
Button("Dump exportieren…", systemImage: "square.and.arrow.up") {
|
||||
exportDump()
|
||||
}
|
||||
.disabled(isWriting || tonuinoWritePending)
|
||||
Button(isWriting ? "Menü wird vorbereitet…" : (tonuinoWritePending ? "Warte auf Karte…" : "Karte programmieren"), systemImage: "wave.3.right") {
|
||||
Task { await writeCard() }
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(isWriting || tonuinoWritePending || !isRouteReady)
|
||||
}
|
||||
}
|
||||
.padding(22)
|
||||
.frame(width: 700, height: 610)
|
||||
.onAppear {
|
||||
route = savedRoute == "acr122u" ? .acr122u : .tonuino
|
||||
app.cards.refreshReaders()
|
||||
app.cards.refreshSerialPorts()
|
||||
}
|
||||
.onChange(of: route) { newValue in savedRoute = newValue.rawValue }
|
||||
.onChange(of: special) { newValue in
|
||||
if mode != .quiz, mode.needsSpecial2, special2 < newValue { special2 = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
private var specialLabel: String {
|
||||
switch mode {
|
||||
case .single: "Tracknummer: \(special)"
|
||||
case .audiobookSingle: "Zusatztracks: \(special)"
|
||||
default: "Starttrack: \(special)"
|
||||
}
|
||||
}
|
||||
|
||||
private func connectSerial() async {
|
||||
do { try await app.cards.connectSerial() }
|
||||
catch { resultMessage = error.localizedDescription; writeSucceeded = false }
|
||||
}
|
||||
|
||||
private func makeCard() throws -> TonuinoCard {
|
||||
let value = TonuinoCard(
|
||||
folder: mode == .admin ? 0 : UInt8(folder.number),
|
||||
mode: mode,
|
||||
special: UInt8(special),
|
||||
special2: UInt8(special2)
|
||||
)
|
||||
try value.validate()
|
||||
return value
|
||||
}
|
||||
|
||||
private func exportDump() {
|
||||
do {
|
||||
let dump = MifareClassicDump.make1K(for: try makeCard())
|
||||
let panel = NSSavePanel()
|
||||
panel.nameFieldStringValue = dumpFilename
|
||||
panel.message = "Speichere einen MIFARE-Classic-1K-Dump für den Pixl.js-Kartenemulator."
|
||||
panel.prompt = "Speichern"
|
||||
panel.canCreateDirectories = true
|
||||
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||
try dump.write(to: url, options: .atomic)
|
||||
resultMessage = "Kartendump erfolgreich exportiert (1.024 Byte)."
|
||||
writeSucceeded = true
|
||||
app.status = "Kartendump für Ordner \(String(format: "%02d", folder.number)) exportiert"
|
||||
} catch {
|
||||
resultMessage = error.localizedDescription
|
||||
writeSucceeded = false
|
||||
}
|
||||
}
|
||||
|
||||
private var dumpFilename: String {
|
||||
let normalizedMode = mode.title
|
||||
.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||||
.lowercased()
|
||||
.map { $0.isLetter || $0.isNumber ? $0 : "-" }
|
||||
let modeName = String(normalizedMode)
|
||||
.split(separator: "-")
|
||||
.joined(separator: "-")
|
||||
return "Tonuino-Ordner-\(String(format: "%02d", folder.number))-\(modeName).bin"
|
||||
}
|
||||
|
||||
private func writeCard() async {
|
||||
isWriting = true
|
||||
resultMessage = nil
|
||||
defer { isWriting = false }
|
||||
do {
|
||||
let value = try makeCard()
|
||||
if route == .tonuino {
|
||||
try await app.cards.writeViaTonuino(value)
|
||||
tonuinoWritePending = true
|
||||
resultMessage = "TonUINO-Schreibassistent läuft. Folge den Audioansagen, lege die Karte auf und klicke nach der Bestätigung auf „Fertig“."
|
||||
writeSucceeded = false
|
||||
app.status = "TonUINO-Schreibassistent gestartet; folge den Audioansagen."
|
||||
} else {
|
||||
try await app.cards.writeViaReader(value)
|
||||
resultMessage = "Karte erfolgreich programmiert."
|
||||
writeSucceeded = true
|
||||
app.status = "Karte für Ordner \(String(format: "%02d", folder.number)) programmiert"
|
||||
}
|
||||
} catch {
|
||||
resultMessage = error.localizedDescription
|
||||
writeSucceeded = false
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,187 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MusicBrainzTaggingView: View {
|
||||
let tracks: [MusicTrack]
|
||||
let onComplete: () -> Void
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var artistQuery: String
|
||||
@State private var albumQuery: String
|
||||
@State private var results: [MusicBrainzRelease] = []
|
||||
@State private var release: MusicBrainzRelease?
|
||||
@State private var mappings: [String: Int] = [:]
|
||||
@State private var isSearching = false
|
||||
@State private var isLoadingRelease = false
|
||||
@State private var isWriting = false
|
||||
@State private var message: String?
|
||||
|
||||
init(tracks: [MusicTrack], onComplete: @escaping () -> Void) {
|
||||
self.tracks = tracks
|
||||
self.onComplete = onComplete
|
||||
_artistQuery = State(initialValue: tracks.first(where: { !$0.albumArtist.isEmpty })?.albumArtist ?? tracks.first?.artist ?? "")
|
||||
_albumQuery = State(initialValue: tracks.first?.album ?? "")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Musik mit MusicBrainz taggen").font(.title2.bold())
|
||||
Text("\(tracks.count) ausgewählte Datei\(tracks.count == 1 ? "" : "en") · Tags werden erst nach deiner Prüfung geschrieben")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button { dismiss() } label: { Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary).font(.title2) }.buttonStyle(.plain)
|
||||
}
|
||||
HStack(spacing: 10) {
|
||||
TextField("Interpret", text: $artistQuery)
|
||||
TextField("Album", text: $albumQuery)
|
||||
Button("Suchen", systemImage: "magnifyingglass") { search() }.disabled(isSearching || isWriting)
|
||||
}
|
||||
if isSearching || isLoadingRelease { ProgressView(isSearching ? "Suche in MusicBrainz…" : "Albumdaten werden geladen…") }
|
||||
if let message { Text(message).font(.callout).foregroundStyle(.secondary) }
|
||||
|
||||
if let release {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Treffer prüfen").font(.headline)
|
||||
Text("\(release.artist) — \(release.title)\(release.year.isEmpty ? "" : " (\(release.year))") · \(release.tracks.count) Albumtracks")
|
||||
.font(.subheadline)
|
||||
Text("Ordne jede Datei dem passenden Albumtrack zu. Die vorgeschlagenen Zuordnungen folgen der Dateinummer.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(tracks) { track in
|
||||
HStack(spacing: 14) {
|
||||
Text("\(track.url.lastPathComponent) · \(track.displayTitle)").lineLimit(1).frame(maxWidth: .infinity, alignment: .leading)
|
||||
Picker("Zuordnung", selection: binding(for: track)) {
|
||||
Text("Nicht schreiben").tag(-1)
|
||||
ForEach(Array(release.tracks.enumerated()), id: \.offset) { index, item in
|
||||
Text("\(item.discNumber).\(item.displayNumber) · \(item.title)\(item.artist.isEmpty ? "" : " — \(item.artist)")").tag(index)
|
||||
}
|
||||
}.labelsHidden().frame(width: 350)
|
||||
}.padding(.vertical, 5).padding(.horizontal, 4)
|
||||
Divider()
|
||||
}
|
||||
}
|
||||
}.frame(minHeight: 180, maxHeight: 330)
|
||||
}
|
||||
} else if !results.isEmpty {
|
||||
Text("Wähle die passende Veröffentlichung:").font(.headline)
|
||||
List(results) { candidate in
|
||||
Button {
|
||||
Task { await load(candidate) }
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(candidate.title).font(.body.weight(.medium))
|
||||
Text("\(candidate.artist)\(candidate.year.isEmpty ? "" : " · \(candidate.year)")")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: "chevron.right").foregroundStyle(.tertiary)
|
||||
}.contentShape(Rectangle())
|
||||
}.buttonStyle(.plain)
|
||||
}.frame(minHeight: 180, maxHeight: 330)
|
||||
}
|
||||
|
||||
HStack {
|
||||
if release != nil { Button("Anderen Treffer suchen") { release = nil; results = [] } }
|
||||
Spacer()
|
||||
Button("Abbrechen") { dismiss() }.keyboardShortcut(.cancelAction)
|
||||
if release != nil {
|
||||
Button(isWriting ? "Schreibe Tags…" : "Zuordnungen schreiben", systemImage: "tag") { writeTags() }
|
||||
.buttonStyle(.borderedProminent).disabled(isWriting)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(22)
|
||||
.frame(width: 860, height: 650)
|
||||
.onAppear { if !albumQuery.isEmpty || !artistQuery.isEmpty { search() } }
|
||||
}
|
||||
|
||||
private func binding(for track: MusicTrack) -> Binding<Int> {
|
||||
Binding(get: { mappings[track.id] ?? -1 }, set: { mappings[track.id] = $0 })
|
||||
}
|
||||
|
||||
private func search() {
|
||||
isSearching = true; message = nil; release = nil; results = []
|
||||
Task {
|
||||
do {
|
||||
let found = try await MusicBrainzService.searchReleases(artist: artistQuery, album: albumQuery)
|
||||
await MainActor.run {
|
||||
results = found
|
||||
message = found.isEmpty ? "Keine Veröffentlichungen gefunden. Verfeinere Interpret oder Album." : nil
|
||||
isSearching = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run { message = error.localizedDescription; isSearching = false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func load(_ candidate: MusicBrainzRelease) async {
|
||||
isLoadingRelease = true; message = nil
|
||||
do {
|
||||
let full = try await MusicBrainzService.loadRelease(id: candidate.id)
|
||||
await MainActor.run {
|
||||
release = full
|
||||
mappings = Dictionary(uniqueKeysWithValues: tracks.map { track in
|
||||
(track.id, suggestedIndex(for: track, in: full.tracks))
|
||||
})
|
||||
isLoadingRelease = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run { message = error.localizedDescription; isLoadingRelease = false }
|
||||
}
|
||||
}
|
||||
|
||||
private func suggestedIndex(for track: MusicTrack, in recordings: [MusicBrainzRecording]) -> Int {
|
||||
let rawNumber = track.trackNumber.isEmpty ? track.url.deletingPathExtension().lastPathComponent : track.trackNumber
|
||||
let number = Int(rawNumber.split(separator: "/").first ?? "")
|
||||
let disc = Int(track.discNumber) ?? 1
|
||||
if let number, let exact = recordings.firstIndex(where: { (Int($0.displayNumber) ?? -1) == number && (Int($0.discNumber) ?? 1) == disc }) { return exact }
|
||||
if let number, recordings.indices.contains(number - 1) { return number - 1 }
|
||||
return -1
|
||||
}
|
||||
|
||||
private func writeTags() {
|
||||
guard let release else { return }
|
||||
let selected = tracks.compactMap { track -> (MusicTrack, MusicBrainzRecording)? in
|
||||
guard let index = mappings[track.id], release.tracks.indices.contains(index) else { return nil }
|
||||
return (track, release.tracks[index])
|
||||
}
|
||||
guard !selected.isEmpty else { message = "Es ist keine Datei einem Albumtrack zugeordnet."; return }
|
||||
isWriting = true; message = "Schreibe Tags auf die SD-Karte…"
|
||||
Task {
|
||||
do {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
for (track, recording) in selected {
|
||||
var metadata = ID3Metadata.read(track.url)
|
||||
metadata.title = recording.title
|
||||
metadata.artist = recording.artist.isEmpty ? release.artist : recording.artist
|
||||
metadata.album = release.title
|
||||
metadata.albumArtist = release.artist
|
||||
metadata.trackNumber = recording.displayNumber
|
||||
metadata.discNumber = recording.discNumber
|
||||
metadata.year = release.year
|
||||
metadata.genre = release.genre
|
||||
metadata.musicBrainzRecordingID = recording.recording?.id ?? ""
|
||||
metadata.musicBrainzReleaseID = release.id
|
||||
try ID3Metadata.write(metadata, to: track.url)
|
||||
try ID3Metadata.verify(metadata, at: track.url)
|
||||
}
|
||||
}.value
|
||||
await MainActor.run {
|
||||
isWriting = false
|
||||
message = "\(selected.count) Datei\(selected.count == 1 ? " wurde" : "en wurden") getaggt."
|
||||
onComplete()
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isWriting = false
|
||||
message = "Fehler beim Schreiben: \(error.localizedDescription)"
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
import Sparkle
|
||||
|
||||
struct SettingsView: View {
|
||||
@EnvironmentObject private var app: AppModel
|
||||
@AppStorage("reader.route") private var defaultRoute = "tonuino"
|
||||
@State private var acoustIDClientKey = ""
|
||||
@State private var acoustIDKeyMessage: String?
|
||||
@State private var profileStorageMessage: String?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Standardwerte") {
|
||||
Picker("Bevorzugter Kartenleser", selection: $defaultRoute) {
|
||||
Text("TonUINO über USB").tag("tonuino")
|
||||
Text("ACR122U").tag("acr122u")
|
||||
}
|
||||
Toggle("Update-Prüfung beim Start", isOn: Binding(
|
||||
get: { AppDelegate.updater?.updater.automaticallyChecksForUpdates ?? true },
|
||||
set: { AppDelegate.updater?.updater.automaticallyChecksForUpdates = $0 }
|
||||
))
|
||||
}
|
||||
Section("Hardware") {
|
||||
LabeledContent("TonUINO-Verbindung", value: app.cards.serialConnected ? app.cards.selectedSerialPort : "Nicht verbunden")
|
||||
LabeledContent("Verfügbare serielle Ports", value: "\(app.cards.serialPorts.count)")
|
||||
LabeledContent("PC/SC Reader", value: "\(app.cards.readerNames.count)")
|
||||
Button("Geräte erneut suchen", systemImage: "arrow.clockwise") { app.cards.refreshSerialPorts(); app.cards.refreshReaders() }
|
||||
}
|
||||
Section("Lokale Profile") {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Speicherort")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(app.library.profileStorageURL.path)
|
||||
.textSelection(.enabled)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.lineLimit(3)
|
||||
}
|
||||
if let error = app.library.profileStorageError {
|
||||
Label("Profilspeicher nicht erreichbar", systemImage: "externaldrive.badge.exclamationmark")
|
||||
.foregroundStyle(.orange)
|
||||
Text("\(error) Die vorhandenen Profile bleiben gespeichert; verbinde das Laufwerk oder wähle einen anderen Speicherort.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
HStack {
|
||||
Button("Speicherort ändern…", systemImage: "folder") { chooseProfileStorageLocation() }
|
||||
if app.library.isProfileStorageCustom {
|
||||
Button("Standardort verwenden", systemImage: "arrow.uturn.backward") {
|
||||
changeProfileStorage(to: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(app.library.isSyncing)
|
||||
if app.library.isSyncing {
|
||||
ProgressView(app.library.syncProgress)
|
||||
.font(.caption)
|
||||
}
|
||||
if let profileStorageMessage {
|
||||
Text(profileStorageMessage)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Text("Profile und ihre Audiodateien werden beim Wechsel vollständig kopiert. Ein externes Laufwerk muss beim Start verfügbar sein; nach dem erneuten Anschließen wird der Speicher automatisch neu eingelesen.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Section("Musikerkennung") {
|
||||
SecureField("AcoustID-Anwendungsschlüssel", text: $acoustIDClientKey)
|
||||
.textContentType(.password)
|
||||
HStack {
|
||||
Button("Schlüssel speichern", systemImage: "key") {
|
||||
do {
|
||||
try AcoustIDCredentials.saveClientKey(acoustIDClientKey)
|
||||
acoustIDKeyMessage = acoustIDClientKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Schlüssel entfernt." : "Schlüssel sicher im macOS-Schlüsselbund gespeichert."
|
||||
} catch { acoustIDKeyMessage = error.localizedDescription }
|
||||
}
|
||||
if let acoustIDKeyMessage { Text(acoustIDKeyMessage).font(.caption).foregroundStyle(.secondary) }
|
||||
}
|
||||
Text("Registriere die App bei AcoustID und trage hier den Anwendungsschlüssel ein. Er wird im macOS-Schlüsselbund gespeichert.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Link("AcoustID-Anwendung registrieren", destination: URL(string: "https://acoustid.org/application")!)
|
||||
}
|
||||
Section("Updates") {
|
||||
Text("Version \(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "Unbekannt")")
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Auf Updates prüfen", systemImage: "arrow.clockwise") { AppDelegate.updater?.checkForUpdates(nil) }
|
||||
.disabled(AppDelegate.updater?.updater.canCheckForUpdates != true)
|
||||
}
|
||||
Section("Über die App") {
|
||||
Text("Tonuino Manager für macOS")
|
||||
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")!)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding(.top, 10)
|
||||
.navigationTitle("Einstellungen")
|
||||
.onAppear { acoustIDClientKey = AcoustIDCredentials.loadClientKey() ?? "" }
|
||||
}
|
||||
|
||||
private func chooseProfileStorageLocation() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.title = "Speicherort für lokale Profile auswählen"
|
||||
panel.prompt = "Diesen Ordner verwenden"
|
||||
panel.canChooseFiles = false
|
||||
panel.canChooseDirectories = true
|
||||
panel.canCreateDirectories = true
|
||||
panel.allowsMultipleSelection = false
|
||||
if panel.runModal() == .OK, let url = panel.url {
|
||||
changeProfileStorage(to: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func changeProfileStorage(to url: URL?) {
|
||||
profileStorageMessage = nil
|
||||
Task {
|
||||
do {
|
||||
try await app.library.changeProfileStorageBase(to: url)
|
||||
profileStorageMessage = "Speicherort geändert."
|
||||
} catch {
|
||||
profileStorageMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TonuinoSettingsView: View {
|
||||
@EnvironmentObject private var app: AppModel
|
||||
@State private var currentPIN = ""
|
||||
@State private var newPIN = ""
|
||||
@State private var minVolume = 5
|
||||
@State private var maxVolume = 25
|
||||
@State private var initialVolume = 15
|
||||
@State private var standbyOption = 5
|
||||
@State private var equalizerOption = 1
|
||||
@State private var pauseWhenRemoved = false
|
||||
@State private var invertButtons = false
|
||||
@State private var adminLockOption = 1
|
||||
@State private var showResetConfirmation = false
|
||||
|
||||
private let equalizers = ["Normal", "Pop", "Rock", "Jazz", "Klassik", "Bass"]
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Verbindung zum TonUINO") {
|
||||
HStack {
|
||||
if app.cards.serialConnected {
|
||||
Label(app.cards.firmwareVersion.map { "Verbunden · \($0)" } ?? "Verbunden · Firmwareversion nicht erkannt", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
Spacer()
|
||||
if app.cards.serialAdminMenuActive {
|
||||
Button("Admin-Menü beenden", systemImage: "stop.circle") {
|
||||
do { try app.cards.exitTonuinoAdminMenu(); app.status = "TonUINO-Adminmenü beendet." }
|
||||
catch { app.report(error) }
|
||||
}
|
||||
}
|
||||
Button("Trennen") { app.cards.disconnectSerial() }
|
||||
} else {
|
||||
Picker("Serieller Port", selection: $app.cards.selectedSerialPort) {
|
||||
ForEach(app.cards.serialPorts, id: \.self) { Text($0).tag($0) }
|
||||
}
|
||||
Button("Suchen", systemImage: "arrow.clockwise") { app.cards.refreshSerialPorts() }
|
||||
Button("Verbinden") {
|
||||
Task {
|
||||
do { try await app.cards.connectSerial() }
|
||||
catch { app.report(error) }
|
||||
}
|
||||
}
|
||||
.disabled(app.cards.selectedSerialPort.isEmpty)
|
||||
}
|
||||
}
|
||||
Text("USB-Menüsteuerung benötigt eine TonUINO-TNG-Firmware mit aktiviertem SerialInputAsCommand. Die Firmware muss im Leerlauf sein. Nach der ersten Änderung bleibt das Admin-Menü für weitere Einstellungen geöffnet; beende es danach hier.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Ausgelesene Geräteeinstellungen") {
|
||||
if let settings = app.cards.tonuinoSettings {
|
||||
Text("Beim Verbinden aus der Startmeldung gelesen. Änderungen, die du danach über das Sprachmenü vornimmst, erscheinen nach erneutem Verbinden.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
if let version = settings.firmwareSettingsVersion { LabeledContent("Einstellungsformat", value: String(version)) }
|
||||
settingsRow("Lautsprecher · Maximum", settings.speakerMaximum)
|
||||
settingsRow("Lautsprecher · Minimum", settings.speakerMinimum)
|
||||
settingsRow("Lautsprecher · Startlautstärke", settings.speakerInitial)
|
||||
settingsRow("Kopfhörer · Maximum", settings.headphoneMaximum)
|
||||
settingsRow("Kopfhörer · Minimum", settings.headphoneMinimum)
|
||||
settingsRow("Kopfhörer · Startlautstärke", settings.headphoneInitial)
|
||||
settingsRow("Equalizer (Firmwarewert)", settings.equalizer)
|
||||
settingsRow("Abschalt-Timer (Firmwarewert)", settings.standbyTimer)
|
||||
settingsRow("Lautstärketasten vertauscht (0/1)", settings.invertedVolumeButtons)
|
||||
settingsRow("Admin-Menü gesperrt (0/1)", settings.adminMenuLocked)
|
||||
settingsRow("Pause bei Kartenentfernung (0/1)", settings.pauseWhenCardRemoved)
|
||||
} else if app.cards.serialConnected {
|
||||
Text("Diese Firmware hat beim Verbinden keine auslesbaren Einstellungswerte ausgegeben. Das ist bei älteren AiO-Versionen möglich. Die Verbindung und die vorhandenen Menüsteuerungen bleiben davon unberührt.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Verbinde den TonUINO, um die von der Firmware ausgegebenen Werte zu lesen.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Lautstärke") {
|
||||
Text("Wähle die drei Lautstärkewerte aus. Auslesbare aktuelle Werte erscheinen im Abschnitt oben.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
HStack {
|
||||
volumePicker("Minimum", selection: $minVolume, values: 1...29)
|
||||
volumePicker("Maximum", selection: $maxVolume, values: 2...30)
|
||||
volumePicker("Startlautstärke", selection: $initialVolume, values: 1...30)
|
||||
}
|
||||
if !validVolume {
|
||||
Text("Das Minimum muss kleiner als das Maximum sein, die Startlautstärke muss dazwischen liegen.")
|
||||
.font(.caption).foregroundStyle(.red)
|
||||
}
|
||||
Button("Lautstärke übernehmen", systemImage: "speaker.wave.2") { applyVolume() }
|
||||
.disabled(!app.cards.serialConnected || app.cards.configuringSettings || !validVolume)
|
||||
}
|
||||
|
||||
Section("Admin-Zugang") {
|
||||
SecureField("Aktuelle Admin-PIN (optional)", text: $currentPIN)
|
||||
.frame(maxWidth: 280)
|
||||
Text("Lass das Feld leer, wenn das Admin-Menü nicht geschützt ist. Eine vorhandene Firmware-PIN besteht aus vier Tastenwerten 1, 2 oder 3.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Abschalt-Timer") {
|
||||
Picker("TonUINO nach Inaktivität ausschalten", selection: $standbyOption) {
|
||||
Text("5 Minuten").tag(1)
|
||||
Text("15 Minuten").tag(2)
|
||||
Text("30 Minuten").tag(3)
|
||||
Text("60 Minuten").tag(4)
|
||||
Text("Nie").tag(5)
|
||||
}
|
||||
HStack {
|
||||
Button("Timer übernehmen", systemImage: "timer") { apply(menuItem: 8, option: standbyOption, message: "Abschalt-Timer an TonUINO übermittelt.") }
|
||||
.disabled(!app.cards.serialConnected || app.cards.configuringSettings)
|
||||
Spacer()
|
||||
settingProgress
|
||||
}
|
||||
}
|
||||
|
||||
Section("Wiedergabe") {
|
||||
Picker("Equalizer", selection: $equalizerOption) {
|
||||
ForEach(equalizers.indices, id: \.self) { index in Text(equalizers[index]).tag(index + 1) }
|
||||
}
|
||||
HStack {
|
||||
Button("Equalizer übernehmen", systemImage: "slider.horizontal.3") { apply(menuItem: 5, option: equalizerOption, message: "Equalizer an TonUINO übermittelt.") }
|
||||
.disabled(!app.cards.serialConnected || app.cards.configuringSettings)
|
||||
Spacer()
|
||||
settingProgress
|
||||
}
|
||||
Toggle("Bei Entfernen der Karte pausieren", isOn: $pauseWhenRemoved)
|
||||
Button("Wiedergabeoption übernehmen", systemImage: "playpause") {
|
||||
apply(menuItem: 13, option: pauseWhenRemoved ? 2 : 1, message: "Verhalten beim Entfernen der Karte an TonUINO übermittelt.")
|
||||
}
|
||||
.disabled(!app.cards.serialConnected || app.cards.configuringSettings)
|
||||
}
|
||||
|
||||
Section("Tasten") {
|
||||
Toggle("Lautstärketasten vertauschen", isOn: $invertButtons)
|
||||
Button("Tastenoption übernehmen", systemImage: "arrow.up.arrow.down") {
|
||||
apply(menuItem: 10, option: invertButtons ? 2 : 1, message: "Tastenoption an TonUINO übermittelt.")
|
||||
}
|
||||
.disabled(!app.cards.serialConnected || app.cards.configuringSettings)
|
||||
}
|
||||
|
||||
Section("Admin-Menü") {
|
||||
Picker("Zugangsschutz", selection: $adminLockOption) {
|
||||
Text("Entsperrt").tag(1)
|
||||
Text("Gesperrt, ohne PIN").tag(2)
|
||||
Text("Gesperrt, mit neuer PIN").tag(3)
|
||||
}
|
||||
if adminLockOption == 3 {
|
||||
SecureField("Neue 4-stellige PIN (nur 1, 2, 3)", text: $newPIN)
|
||||
.frame(maxWidth: 280)
|
||||
}
|
||||
Button("Zugangsschutz übernehmen", systemImage: "lock") {
|
||||
apply(menuItem: 12, option: adminLockOption, message: "Zugangsschutz an TonUINO übermittelt.", newPIN: adminLockOption == 3 ? newPIN : "")
|
||||
}
|
||||
.disabled(!app.cards.serialConnected || app.cards.configuringSettings || (adminLockOption == 3 && (newPIN.count != 4 || !newPIN.allSatisfy({ ["1", "2", "3"].contains(String($0)) }))))
|
||||
}
|
||||
|
||||
Section("Zurücksetzen") {
|
||||
Text("Setzt die in der TonUINO-Firmware gespeicherten Einstellungen auf Werkseinstellungen zurück.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
Button("TonUINO-Einstellungen zurücksetzen…", systemImage: "arrow.counterclockwise", role: .destructive) {
|
||||
showResetConfirmation = true
|
||||
}
|
||||
.disabled(!app.cards.serialConnected || app.cards.configuringSettings)
|
||||
}
|
||||
|
||||
Section {
|
||||
Text("Die App steuert Änderungen über das Admin-Sprachmenü der Firmware. Folge den TonUINO-Audioansagen und achte auf die Bestätigung. Das automatische Auslesen hängt davon ab, ob die Firmware ihre Einstellungen beim Start über USB meldet.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding(.top, 10)
|
||||
.navigationTitle("TonUINO-Einstellungen")
|
||||
.onAppear { app.cards.refreshSerialPorts() }
|
||||
.confirmationDialog("TonUINO-Einstellungen zurücksetzen?", isPresented: $showResetConfirmation, titleVisibility: .visible) {
|
||||
Button("Einstellungen zurücksetzen", role: .destructive) {
|
||||
Task {
|
||||
do {
|
||||
try await app.cards.resetTonuinoSettings(currentPIN: currentPIN)
|
||||
app.status = "TonUINO-Einstellungen wurden zurückgesetzt."
|
||||
} catch { app.report(error) }
|
||||
}
|
||||
}
|
||||
Button("Abbrechen", role: .cancel) { }
|
||||
} message: {
|
||||
Text("Alle TonUINO-Einstellungen im EEPROM werden gelöscht. Die SD-Karte und NFC-Karten bleiben unverändert.")
|
||||
}
|
||||
.overlay {
|
||||
if app.cards.configuringSettings {
|
||||
ProgressView("Einstellungen werden übertragen…")
|
||||
.padding(18)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var settingProgress: some View {
|
||||
Group {
|
||||
if app.cards.configuringSettings { ProgressView().controlSize(.small) }
|
||||
}
|
||||
}
|
||||
|
||||
private var validVolume: Bool {
|
||||
return minVolume < maxVolume && (minVolume...maxVolume).contains(initialVolume)
|
||||
}
|
||||
|
||||
private func volumePicker(_ title: String, selection: Binding<Int>, values: ClosedRange<Int>) -> some View {
|
||||
Picker(title, selection: selection) {
|
||||
ForEach(values, id: \.self) { value in
|
||||
Text(String(value)).tag(value)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 180)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func settingsRow(_ title: String, _ value: Int?) -> some View {
|
||||
if let value {
|
||||
LabeledContent(title, value: String(value))
|
||||
}
|
||||
}
|
||||
|
||||
private func applyVolume() {
|
||||
Task {
|
||||
do {
|
||||
try await app.cards.applyTonuinoVolume(minimum: minVolume, maximum: maxVolume, initial: initialVolume, currentPIN: currentPIN)
|
||||
app.status = "Lautstärkeeinstellungen an TonUINO übermittelt."
|
||||
} catch { app.report(error) }
|
||||
}
|
||||
}
|
||||
|
||||
private func apply(menuItem: Int, option: Int, message: String, newPIN: String = "") {
|
||||
Task {
|
||||
do {
|
||||
try await app.cards.applyTonuinoSetting(menuItem: menuItem, option: option, currentPIN: currentPIN, newPIN: newPIN)
|
||||
if menuItem == 12 {
|
||||
currentPIN = option == 3 ? newPIN : ""
|
||||
}
|
||||
app.status = message
|
||||
} catch { app.report(error) }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import SwiftUI
|
||||
|
||||
struct YouTubeImportView: View {
|
||||
let folderNumber: Int
|
||||
let onComplete: () -> Void
|
||||
@EnvironmentObject private var app: AppModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var urlText = ""
|
||||
@State private var video: YouTubeVideoInfo?
|
||||
@State private var splitByChapters = false
|
||||
@State private var isInspecting = false
|
||||
@State private var isImporting = false
|
||||
@State private var imported = false
|
||||
@State private var errorMessage: String?
|
||||
|
||||
private var chapters: [YouTubeChapter] { video?.usableChapters ?? [] }
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("YouTube-Audio importieren").font(.title2.bold())
|
||||
Text("Ziel: Ordner \(String(format: "%02d", folderNumber))")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
HStack {
|
||||
TextField("https://www.youtube.com/watch?v=…", text: $urlText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit { inspectVideo() }
|
||||
.onChange(of: urlText) { _ in video = nil; imported = false; errorMessage = nil }
|
||||
Button("Video prüfen", systemImage: "magnifyingglass") { inspectVideo() }
|
||||
.disabled(isInspecting || isImporting || urlText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
|
||||
if isInspecting {
|
||||
ProgressView("YouTube-Video wird analysiert…")
|
||||
} else if let video {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(video.title).font(.headline).lineLimit(2)
|
||||
Text([video.artist.isEmpty ? nil : video.artist, durationText(video.duration)].compactMap { $0 }.joined(separator: " · "))
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Picker("Importart", selection: $splitByChapters) {
|
||||
Text("Gesamtes Video als eine MP3").tag(false)
|
||||
Text("Einzeldatei je Kapitel (\(chapters.count))").tag(true).disabled(chapters.isEmpty)
|
||||
}
|
||||
if chapters.isEmpty {
|
||||
Text("Keine Kapitel gefunden. Das Video kann als eine Datei importiert werden.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Kapitelvorschau").font(.subheadline.weight(.semibold))
|
||||
ForEach(Array(chapters.indices.prefix(6)), id: \.self) { index in
|
||||
let chapter = chapters[index]
|
||||
HStack(spacing: 8) {
|
||||
Text(String(format: "%02d", index + 1)).foregroundStyle(.secondary).monospacedDigit()
|
||||
Text(chapter.title?.isEmpty == false ? chapter.title! : "Kapitel \(index + 1)").lineLimit(1)
|
||||
Spacer()
|
||||
Text(durationText((chapter.end ?? video.duration).map { $0 - chapter.start }))
|
||||
.foregroundStyle(.secondary).monospacedDigit()
|
||||
}.font(.caption)
|
||||
}
|
||||
if chapters.count > 6 { Text("und \(chapters.count - 6) weitere Kapitel…").font(.caption).foregroundStyle(.secondary) }
|
||||
}
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.35), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if isImporting {
|
||||
ProgressView("Audio wird heruntergeladen, konvertiert und importiert…")
|
||||
} else if let errorMessage {
|
||||
Label(errorMessage, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.callout).foregroundStyle(.red).fixedSize(horizontal: false, vertical: true)
|
||||
} else if imported {
|
||||
Label("Audio wurde in Ordner \(String(format: "%02d", folderNumber)) importiert.", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
HStack {
|
||||
Text("yt-dlp lädt die Audiospur; FFmpeg erstellt MP3-Dateien mit Titel- und Interpret-Tags.")
|
||||
.font(.caption).foregroundStyle(.secondary).frame(maxWidth: .infinity, alignment: .leading)
|
||||
Button("Schließen") { dismiss() }.keyboardShortcut(.cancelAction)
|
||||
Button("Importieren", systemImage: "arrow.down.to.line") { importVideo() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(video == nil || isInspecting || isImporting || (splitByChapters && chapters.isEmpty))
|
||||
}
|
||||
}
|
||||
.padding(22)
|
||||
.frame(width: 640, height: 540)
|
||||
}
|
||||
|
||||
private func inspectVideo() {
|
||||
let value = urlText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !value.isEmpty else { return }
|
||||
isInspecting = true; errorMessage = nil; imported = false
|
||||
Task {
|
||||
do {
|
||||
let result = try await Task.detached(priority: .userInitiated) { try YouTubeImportService.inspect(value) }.value
|
||||
video = result
|
||||
splitByChapters = false
|
||||
isInspecting = false
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
isInspecting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func importVideo() {
|
||||
guard let video else { return }
|
||||
isImporting = true; errorMessage = nil; imported = false
|
||||
Task {
|
||||
do {
|
||||
try await app.library.importYouTube(video, splitByChapters: splitByChapters, to: folderNumber)
|
||||
isImporting = false; imported = true
|
||||
app.status = "YouTube-Audio in Ordner \(String(format: "%02d", folderNumber)) importiert"
|
||||
onComplete()
|
||||
} catch {
|
||||
isImporting = false; errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func durationText(_ seconds: Double?) -> String {
|
||||
guard let seconds, seconds.isFinite, seconds >= 0 else { return "" }
|
||||
let total = Int(seconds.rounded())
|
||||
return total >= 3600 ? String(format: "%d:%02d:%02d", total / 3600, (total / 60) % 60, total % 60) : String(format: "%d:%02d", total / 60, total % 60)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user