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,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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user