Add Tonuino Manager website and Sparkle updates

This commit is contained in:
maddin committed 2026-10-31 16:40:48 +01:00
commit 1cac427ff2
44 files changed
+5861

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
import AppKit
import Combine
import SwiftUI
@MainActor
final class AppModel: ObservableObject {
enum Section: String, CaseIterable, Identifiable, Hashable {
case library = "SD-Karte"
case cards = "NFC-Karten"
case tonuino = "TonUINO"
case settings = "Einstellungen"
var id: String { rawValue }
var icon: String {
switch self {
case .library: "sdcard"
case .cards: "wave.3.right"
case .tonuino: "slider.horizontal.3"
case .settings: "gearshape"
}
}
}
@Published var section: Section = .library
@Published var library = LibraryModel()
@Published var cards = CardModel()
@Published var status = "Bereit"
@Published var errorMessage: String?
@Published var showError = false
private var childSubscriptions = Set<AnyCancellable>()
init() {
library.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() }.store(in: &childSubscriptions)
cards.objectWillChange.sink { [weak self] _ in self?.objectWillChange.send() }.store(in: &childSubscriptions)
}
func report(_ error: Error) {
errorMessage = error.localizedDescription
showError = true
}
}
@@ -0,0 +1,53 @@
import SwiftUI
struct ContentView: View {
@EnvironmentObject private var app: AppModel
var body: some View {
VStack(spacing: 0) {
NavigationSplitView {
List(AppModel.Section.allCases, selection: $app.section) { item in
Label(item.rawValue, systemImage: item.icon).tag(item)
}
.listStyle(.sidebar)
.navigationTitle("Tonuino Manager")
.safeAreaInset(edge: .bottom) {
HStack(spacing: 8) {
Circle().fill(app.library.rootURL == nil ? Color.secondary : Color.green).frame(width: 8, height: 8)
Text(app.library.activeProfile.map { "Lokal · \($0.name)" } ?? (app.library.rootURL?.lastPathComponent ?? "Keine Bibliothek ausgewählt"))
.lineLimit(1).foregroundStyle(.secondary)
}
.font(.caption).padding()
}
} detail: {
switch app.section {
case .library: LibraryView()
case .cards: CardsView()
case .tonuino: TonuinoSettingsView()
case .settings: SettingsView()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
HStack {
Spacer(minLength: 0)
Text(app.status)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: 520, alignment: .trailing)
}
.padding(.horizontal, 14)
.padding(.vertical, 6)
.frame(maxWidth: .infinity)
.background(.regularMaterial)
.overlay(alignment: .top) {
Rectangle().fill(.primary.opacity(0.08)).frame(height: 1)
}
}
.alert("Fehler", isPresented: $app.showError) {
Button("OK", role: .cancel) { }
} message: { Text(app.errorMessage ?? "Unbekannter Fehler") }
}
}
@@ -0,0 +1,35 @@
import SwiftUI
import Sparkle
import AppKit
final class AppDelegate: NSObject, NSApplicationDelegate {
static var updater: SPUStandardUpdaterController?
func applicationDidFinishLaunching(_ notification: Notification) {
guard let feed = Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String,
URL(string: feed)?.scheme != nil,
let key = Bundle.main.object(forInfoDictionaryKey: "SUPublicEDKey") as? String,
!key.isEmpty else { return }
Self.updater = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)
}
}
@main
struct TonuinoManagerApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@StateObject private var model = AppModel()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(model)
.frame(minWidth: 980, minHeight: 640)
}
.commands {
CommandGroup(replacing: .newItem) { }
CommandGroup(after: .appInfo) {
Button("Auf Updates prüfen…") { AppDelegate.updater?.checkForUpdates(nil) }
.disabled(AppDelegate.updater?.updater.canCheckForUpdates != true)
}
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

@@ -0,0 +1,68 @@
{
"images": [
{
"filename": "AppIcon-16.png",
"idiom": "mac",
"scale": "1x",
"size": "16x16"
},
{
"filename": "AppIcon-32.png",
"idiom": "mac",
"scale": "2x",
"size": "16x16"
},
{
"filename": "AppIcon-32-1x.png",
"idiom": "mac",
"scale": "1x",
"size": "32x32"
},
{
"filename": "AppIcon-64.png",
"idiom": "mac",
"scale": "2x",
"size": "32x32"
},
{
"filename": "AppIcon-128.png",
"idiom": "mac",
"scale": "1x",
"size": "128x128"
},
{
"filename": "AppIcon-256.png",
"idiom": "mac",
"scale": "2x",
"size": "128x128"
},
{
"filename": "AppIcon-256-1x.png",
"idiom": "mac",
"scale": "1x",
"size": "256x256"
},
{
"filename": "AppIcon-512.png",
"idiom": "mac",
"scale": "2x",
"size": "256x256"
},
{
"filename": "AppIcon-512-1x.png",
"idiom": "mac",
"scale": "1x",
"size": "512x512"
},
{
"filename": "AppIcon-1024.png",
"idiom": "mac",
"scale": "2x",
"size": "512x512"
}
],
"info": {
"author": "xcode",
"version": 1
}
}
@@ -0,0 +1,34 @@
import Foundation
struct MusicFolder: Identifiable, Hashable, Sendable {
let number: Int
let url: URL
var tracks: [MusicTrack]
var customName: String? = nil
var id: Int { number }
var title: String { customName ?? tracks.first(where: { !$0.album.isEmpty })?.album ?? "Ordner \(String(format: "%02d", number))" }
}
struct MusicTrack: Identifiable, Hashable, Sendable {
let url: URL
var title: String
var artist: String
var album: String
var albumArtist: String = ""
var trackNumber: String = ""
var discNumber: String = ""
var year: String = ""
var genre: String = ""
var acoustID: String = ""
var musicBrainzRecordingID: String = ""
var musicBrainzReleaseID: String = ""
var id: String { url.path }
var displayTitle: String { title.isEmpty ? url.deletingPathExtension().lastPathComponent : title }
}
struct SystemMusicFolder: Identifiable, Hashable, Sendable {
let name: String
let url: URL
var tracks: [MusicTrack]
var id: String { name }
}
@@ -0,0 +1,116 @@
import Foundation
enum PlaybackMode: Int, CaseIterable, Identifiable, Hashable {
case hoerspiel = 1, album, party, single, audiobook, admin = 255
case hoerspielRange = 7, albumRange, partyRange, audiobookSingle, repeatLast, quiz, memory, bluetooth, teapot, audiobookRange
var id: Int { rawValue }
var title: String {
switch self {
case .hoerspiel: "Hörspiel"; case .album: "Album"; case .party: "Party"; case .single: "Einzelner Track"
case .audiobook: "Hörbuch"; case .admin: "Admin-Karte"; case .hoerspielRange: "Hörspiel von-bis"
case .albumRange: "Album von-bis"; case .partyRange: "Party von-bis"; case .audiobookSingle: "Hörbuch einzeln"
case .repeatLast: "Letzte Karte wiederholen"; case .quiz: "Quiz-Spiel"; case .memory: "Memory-Spiel"
case .bluetooth: "Bluetooth umschalten"; case .teapot: "Teekesselchen-Spiel"; case .audiobookRange: "Hörbuch von-bis"
}
}
var needsFolder: Bool { ![.repeatLast, .bluetooth].contains(self) }
var needsSpecial: Bool { [.single, .audiobookSingle, .hoerspielRange, .albumRange, .partyRange, .quiz, .audiobookRange].contains(self) }
var needsSpecial2: Bool { [.hoerspielRange, .albumRange, .partyRange, .quiz, .audiobookRange].contains(self) }
}
enum TonuinoModifier: Int, CaseIterable, Identifiable, Hashable {
case sleepTimer = 1
case freezeDance
case fireWaterAir
case toddlerMode
case kindergartenMode
case repeatSingle
case bluetoothToggle
case jukebox
case pauseAfterTrack
case standbyTimerToggle
case endlessToggle
var id: Int { rawValue }
var title: String {
switch self {
case .sleepTimer: "Einschlaf-Timer"
case .freezeDance: "Stopptanz"
case .fireWaterAir: "Feuer, Wasser, Luft"
case .toddlerMode: "Kleinkindmodus"
case .kindergartenMode: "Kindergartenmodus"
case .repeatSingle: "Track wiederholen"
case .bluetoothToggle: "Bluetooth umschalten"
case .jukebox: "Jukebox"
case .pauseAfterTrack: "Nach jedem Track pausieren"
case .standbyTimerToggle: "Standby-Timer umschalten"
case .endlessToggle: "Endlosmodus umschalten"
}
}
var firmwareNote: String? {
switch self {
case .bluetoothToggle: "Benötigt eine TonUINO-Firmware mit BT_MODULE."
case .jukebox: "Benötigt eine TonUINO-Firmware mit MODIFICATION_CARD_JUKEBOX."
case .pauseAfterTrack: "Benötigt eine TonUINO-Firmware mit MODIFICATION_CARD_PAUSE_AFTER_TRACK."
case .standbyTimerToggle: "Benötigt TonUINO-TNG 3.3.2 oder neuer."
case .endlessToggle: "Benötigt TonUINO-TNG 3.3.3 oder neuer."
default: nil
}
}
}
struct TonuinoCard: Equatable {
var folder: UInt8
var mode: PlaybackMode
var special: UInt8 = 0
var special2: UInt8 = 0
var modifier: TonuinoModifier? = nil
var displayTitle: String { modifier?.title ?? mode.title }
func payload() -> Data {
Data([0x13, 0x37, 0xB3, 0x47, 0x02, folder, UInt8(modifier?.rawValue ?? mode.rawValue), special, special2] + Array(repeating: 0, count: 7))
}
static func decode(_ bytes: Data) -> TonuinoCard? {
guard bytes.count >= 9, Array(bytes.prefix(4)) == [0x13, 0x37, 0xB3, 0x47], bytes[4] > 0, bytes[4] <= 2,
let mode = PlaybackMode(rawValue: Int(bytes[6])) else { return nil }
let folder = bytes[5]
let modifier = folder == 0 ? TonuinoModifier(rawValue: Int(bytes[6])) : nil
return TonuinoCard(folder: folder, mode: mode, special: bytes[7], special2: bytes[8], modifier: modifier)
}
static func makeModifier(_ modifier: TonuinoModifier, special: UInt8 = 0) -> TonuinoCard {
let fallbackMode = PlaybackMode(rawValue: modifier.rawValue) ?? .admin
return TonuinoCard(folder: 0, mode: fallbackMode, special: special, modifier: modifier)
}
}
enum MifareClassicDump {
static func make1K(for card: TonuinoCard) -> Data {
var dump = Data(repeating: 0, count: 1024)
let uid = (0..<4).map { _ in UInt8.random(in: 0...255) }
let bcc = uid.reduce(UInt8(0), ^)
let manufacturerBlock = uid + [bcc, 0x08, 0x04, 0x00] + Array(repeating: UInt8(0), count: 8)
dump.replaceSubrange(0..<16, with: manufacturerBlock)
// TonUINO stores its 16-byte card payload in MIFARE Classic block 4.
dump.replaceSubrange(64..<80, with: card.payload())
// Use the standard transport configuration and default FF keys in each sector trailer.
let sectorTrailer: [UInt8] = [
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0x07, 0x80, 0x69,
0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF
]
for block in stride(from: 3, through: 63, by: 4) {
let start = block * 16
dump.replaceSubrange(start..<(start + 16), with: sectorTrailer)
}
return dump
}
}
@@ -0,0 +1,248 @@
import Foundation
import Security
struct AcoustIDRelease: Decodable, Identifiable, Hashable, Sendable {
let id: String
let title: String
let date: String?
let artists: [AcoustIDArtist]?
var artist: String { artists?.map(\.name).filter { !$0.isEmpty }.joined(separator: ", ") ?? "" }
var year: String { String((date ?? "").prefix(4)) }
private enum CodingKeys: String, CodingKey { case id, title, date, artists }
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(String.self, forKey: .id)
title = try values.decode(String.self, forKey: .title)
artists = try values.decodeIfPresent([AcoustIDArtist].self, forKey: .artists)
if let stringDate = try? values.decode(String.self, forKey: .date) {
date = stringDate
} else if let parts = try? values.decode(DateParts.self, forKey: .date) {
let year = parts.year?.value
let month = parts.month?.value
let day = parts.day?.value
if let year {
date = [year, month.map { String(format: "%02d", Int($0) ?? 0) }, day.map { String(format: "%02d", Int($0) ?? 0) }]
.compactMap { $0 }
.joined(separator: "-")
} else {
date = nil
}
} else {
date = nil
}
}
private struct DateParts: Decodable {
let year: DatePart?
let month: DatePart?
let day: DatePart?
}
private struct DatePart: Decodable {
let value: String
init(from decoder: Decoder) throws {
let value = try decoder.singleValueContainer()
if let string = try? value.decode(String.self) {
self.value = string
} else if let integer = try? value.decode(Int.self) {
self.value = String(integer)
} else {
throw DecodingError.typeMismatch(String.self, .init(codingPath: decoder.codingPath, debugDescription: "Expected a date component as a string or integer."))
}
}
}
}
struct AcoustIDArtist: Decodable, Hashable, Sendable {
let id: String?
let name: String
}
struct AcoustIDMatch: Identifiable, Hashable, Sendable {
let acoustID: String
let score: Double
let recordingID: String
let title: String
let artist: String
let releases: [AcoustIDRelease]
var id: String { "\(acoustID):\(recordingID)" }
}
enum AcoustIDCredentials {
private static let service = Bundle.main.bundleIdentifier ?? "app.maddin.tonuino"
private static let account = "acoustid-client-key"
static func loadClientKey() -> String? {
var query = baseQuery
query[kSecReturnData as String] = true
query[kSecMatchLimit as String] = kSecMatchLimitOne
var result: CFTypeRef?
guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
let data = result as? Data,
let value = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!value.isEmpty else { return nil }
return value
}
static func saveClientKey(_ value: String) throws {
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalized.isEmpty else {
let status = SecItemDelete(baseQuery as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else { throw AcoustIDError.keychain(status) }
return
}
let data = Data(normalized.utf8)
let update = [kSecValueData as String: data]
let updateStatus = SecItemUpdate(baseQuery as CFDictionary, update as CFDictionary)
if updateStatus == errSecItemNotFound {
var query = baseQuery
query[kSecValueData as String] = data
query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlocked
let addStatus = SecItemAdd(query as CFDictionary, nil)
guard addStatus == errSecSuccess else { throw AcoustIDError.keychain(addStatus) }
} else if updateStatus != errSecSuccess {
throw AcoustIDError.keychain(updateStatus)
}
}
private static var baseQuery: [String: Any] {
[kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account]
}
}
enum AcoustIDService {
private static let endpoint = URL(string: "https://api.acoustid.org/v2/lookup")!
private static let session: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 30
return URLSession(configuration: configuration)
}()
static func identify(_ file: URL, clientKey: String) async throws -> [AcoustIDMatch] {
guard !clientKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw AcoustIDError.missingClientKey }
guard let fpcalc = MediaToolLocator.executable(named: "fpcalc") else { throw MediaToolError.missing("Chromaprint fpcalc") }
let fingerprintData = try await Task.detached(priority: .userInitiated) {
try MediaProcess.run(fpcalc, arguments: ["-json", "-length", "120", file.path])
}.value
let fingerprint: FingerprintOutput
do { fingerprint = try JSONDecoder().decode(FingerprintOutput.self, from: fingerprintData) }
catch { throw AcoustIDError.invalidFingerprint(file.lastPathComponent) }
guard fingerprint.duration > 0, !fingerprint.fingerprint.isEmpty else { throw AcoustIDError.invalidFingerprint(file.lastPathComponent) }
await LookupRateGate.shared.wait()
var request = URLRequest(url: endpoint)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.httpBody = formBody([
"client": clientKey,
"duration": String(Int(fingerprint.duration.rounded())),
"fingerprint": fingerprint.fingerprint,
"format": "json",
"meta": "recordings+releases"
])
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw AcoustIDError.http((response as? HTTPURLResponse)?.statusCode ?? -1)
}
let payload: LookupResponse
do { payload = try JSONDecoder().decode(LookupResponse.self, from: data) }
catch { throw AcoustIDError.invalidResponse(Self.responseDecodingFailure(error, data: data, response: response)) }
guard payload.status == "ok" else { throw AcoustIDError.service(payload.error?.message ?? "Unbekannter AcoustID-Fehler") }
return (payload.results ?? []).flatMap { result in
(result.recordings ?? []).map { recording in
let artist = recording.artists?.map(\.name).filter { !$0.isEmpty }.joined(separator: ", ") ?? ""
return AcoustIDMatch(
acoustID: result.id,
score: result.score,
recordingID: recording.id,
title: recording.title,
artist: artist,
releases: recording.releases ?? []
)
}
}.sorted { $0.score > $1.score }
}
private static func formBody(_ values: [String: String]) -> Data {
var components = URLComponents()
components.queryItems = values.keys.sorted().map { URLQueryItem(name: $0, value: values[$0]) }
return Data((components.percentEncodedQuery ?? "").utf8)
}
private static func responseDecodingFailure(_ error: Error, data: Data, response: URLResponse) -> String {
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
let contentType = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Type") ?? "unbekannt"
let body = String(data: data.prefix(500), encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "(keine lesbare Antwort)"
let detail: String
if let decodingError = error as? DecodingError {
switch decodingError {
case .keyNotFound(let key, let context):
detail = "Feld '\(key.stringValue)' fehlt (\(context.codingPath.map(\.stringValue).joined(separator: ".")))."
case .typeMismatch(let type, let context):
detail = "Unerwarteter Datentyp für \(context.codingPath.map(\.stringValue).joined(separator: ".")) (\(type))."
case .valueNotFound(let type, let context):
detail = "Leerer Wert für \(context.codingPath.map(\.stringValue).joined(separator: ".")) (\(type))."
case .dataCorrupted(let context):
detail = context.debugDescription
@unknown default:
detail = error.localizedDescription
}
} else {
detail = error.localizedDescription
}
return "HTTP \(status), Content-Type \(contentType): \(detail) Antwort: \(body)"
}
private struct FingerprintOutput: Decodable { let duration: Double; let fingerprint: String }
private struct LookupResponse: Decodable {
let status: String
let error: ServiceError?
let results: [LookupResult]?
struct ServiceError: Decodable { let message: String? }
}
private struct LookupResult: Decodable { let id: String; let score: Double; let recordings: [Recording]?
struct Recording: Decodable {
let id: String
let title: String
let artists: [AcoustIDArtist]?
let releases: [AcoustIDRelease]?
}
}
private actor LookupRateGate {
static let shared = LookupRateGate()
private var lastRequest = Date.distantPast
func wait() async {
let delay = max(0, 0.36 - Date().timeIntervalSince(lastRequest))
lastRequest = Date().addingTimeInterval(delay)
if delay > 0 { try? await Task.sleep(for: .seconds(delay)) }
}
}
}
enum AcoustIDError: LocalizedError {
case missingClientKey, invalidFingerprint(String), invalidResponse(String), http(Int), service(String), keychain(OSStatus)
var errorDescription: String? {
switch self {
case .missingClientKey: "Trage zuerst den AcoustID-Anwendungsschlüssel in den Einstellungen ein."
case .invalidFingerprint(let file): "Für \(file) konnte kein Audiofingerabdruck erstellt werden."
case .invalidResponse(let detail): "Die AcoustID-Antwort konnte nicht gelesen werden: \(detail)"
case .http(let status): "AcoustID ist mit HTTP-Status \(status) fehlgeschlagen."
case .service(let message): "AcoustID-Fehler: \(message)"
case .keychain(let status): "Der AcoustID-Schlüssel konnte nicht im Schlüsselbund gespeichert werden (\(status))."
}
}
}
@@ -0,0 +1,153 @@
import AVFoundation
import Combine
import Foundation
import MediaPlayer
@MainActor
final class AudioPlayerModel: ObservableObject {
@Published private(set) var title = ""
@Published private(set) var artist = ""
@Published private(set) var duration: Double = 0
@Published private(set) var position: Double = 0
@Published private(set) var isPlaying = false
@Published private(set) var isLoading = false
@Published private(set) var statusMessage = "Bereit"
@Published private(set) var currentURL: URL?
@Published var volume: Double = 0.8 { didSet { player.volume = Float(volume) } }
private let player = AVPlayer()
private let commandCenter = MPRemoteCommandCenter.shared()
private var observer: Any?
private var itemObservation: NSKeyValueObservation?
private var endObserver: NSObjectProtocol?
private var remoteCommandTargets: [(MPRemoteCommand, Any)] = []
private var queue: [MusicTrack] = []
private var currentIndex = 0
init() {
player.automaticallyWaitsToMinimizeStalling = true
installRemoteCommands()
observer = player.addPeriodicTimeObserver(forInterval: CMTime(seconds: 0.25, preferredTimescale: 600), queue: .main) { [weak self] time in
Task { @MainActor [weak self] in
guard let self else { return }
self.position = time.seconds.isFinite ? time.seconds : 0
let seconds = self.player.currentItem?.duration.seconds ?? 0
if seconds.isFinite { self.duration = seconds }
self.isPlaying = self.player.rate > 0
self.updateNowPlayingInfo()
}
}
}
deinit {
if let observer { player.removeTimeObserver(observer) }
if let endObserver { NotificationCenter.default.removeObserver(endObserver) }
for (command, target) in remoteCommandTargets { command.removeTarget(target) }
}
func play(_ tracks: [MusicTrack], startingAt index: Int) {
guard tracks.indices.contains(index) else { return }
queue = tracks; currentIndex = index
loadCurrent(autoplay: true)
}
func isCurrentTrack(in tracks: [MusicTrack]) -> Bool {
guard let currentURL else { return false }
return tracks.contains { $0.url.standardizedFileURL == currentURL.standardizedFileURL }
}
func toggle() {
guard currentURL != nil else { statusMessage = "Wähle zuerst einen Track aus."; return }
if isPlaying { player.pause(); isPlaying = false; statusMessage = "Pausiert" }
else if player.currentItem?.status == .failed { loadCurrent(autoplay: true) }
else { player.play(); isPlaying = true; statusMessage = "Wiedergabe wird gestartet…" }
updateNowPlayingInfo()
}
func seek(_ value: Double) { player.seek(to: CMTime(seconds: value, preferredTimescale: 600)) }
func next() { guard currentIndex + 1 < queue.count else { return }; currentIndex += 1; loadCurrent(autoplay: true) }
func previous() { guard currentIndex > 0 else { return }; currentIndex -= 1; loadCurrent(autoplay: true) }
private func loadCurrent(autoplay: Bool) {
let track = queue[currentIndex]
title = track.displayTitle; artist = track.artist; currentURL = track.url
duration = 0; position = 0; isPlaying = false; isLoading = true
statusMessage = "Lade \(track.url.lastPathComponent)…"
updateNowPlayingInfo()
if let endObserver { NotificationCenter.default.removeObserver(endObserver); self.endObserver = nil }
itemObservation = nil
let item = AVPlayerItem(url: track.url)
itemObservation = item.observe(\.status, options: [.initial, .new]) { [weak self] item, _ in
Task { @MainActor in
guard let self, self.currentURL == track.url else { return }
switch item.status {
case .readyToPlay:
self.isLoading = false
let seconds = item.duration.seconds
if seconds.isFinite { self.duration = seconds }
self.statusMessage = autoplay ? "Wiedergabe" : "Bereit zum Abspielen"
if autoplay { self.player.play(); self.isPlaying = true }
self.updateNowPlayingInfo()
case .failed:
self.isLoading = false; self.isPlaying = false
self.statusMessage = "Wiedergabe fehlgeschlagen: \(item.error?.localizedDescription ?? "Unbekannter Fehler")"
self.updateNowPlayingInfo()
case .unknown: break
@unknown default: break
}
}
}
player.replaceCurrentItem(with: item)
endObserver = NotificationCenter.default.addObserver(forName: .AVPlayerItemDidPlayToEndTime, object: item, queue: .main) { [weak self] _ in
Task { @MainActor in self?.next() }
}
}
private func installRemoteCommands() {
let toggleTarget = commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
Task { @MainActor [weak self] in self?.toggle() }
return .success
}
let playTarget = commandCenter.playCommand.addTarget { [weak self] _ in
Task { @MainActor [weak self] in
guard let self, self.currentURL != nil, !self.isPlaying else { return }
self.toggle()
}
return .success
}
let pauseTarget = commandCenter.pauseCommand.addTarget { [weak self] _ in
Task { @MainActor [weak self] in
guard let self, self.isPlaying else { return }
self.toggle()
}
return .success
}
let nextTarget = commandCenter.nextTrackCommand.addTarget { [weak self] _ in
Task { @MainActor [weak self] in self?.next() }
return .success
}
let previousTarget = commandCenter.previousTrackCommand.addTarget { [weak self] _ in
Task { @MainActor [weak self] in self?.previous() }
return .success
}
remoteCommandTargets = [
(commandCenter.togglePlayPauseCommand, toggleTarget),
(commandCenter.playCommand, playTarget),
(commandCenter.pauseCommand, pauseTarget),
(commandCenter.nextTrackCommand, nextTarget),
(commandCenter.previousTrackCommand, previousTarget)
]
}
private func updateNowPlayingInfo() {
var info: [String: Any] = [
MPMediaItemPropertyTitle: title,
MPMediaItemPropertyArtist: artist,
MPNowPlayingInfoPropertyElapsedPlaybackTime: position,
MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0
]
if duration > 0 { info[MPMediaItemPropertyPlaybackDuration] = duration }
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
}
}
@@ -0,0 +1,252 @@
import Foundation
struct TrackMetadata: Sendable {
var title = ""
var artist = ""
var album = ""
var albumArtist = ""
var trackNumber = ""
var discNumber = ""
var year = ""
var genre = ""
var acoustID = ""
var musicBrainzRecordingID = ""
var musicBrainzReleaseID = ""
}
enum ID3Metadata {
private static let standardFrames: [String: WritableKeyPath<TrackMetadata, String>] = [
"TIT2": \.title, "TPE1": \.artist, "TALB": \.album, "TPE2": \.albumArtist,
"TRCK": \.trackNumber, "TPOS": \.discNumber, "TDRC": \.year, "TYER": \.year, "TCON": \.genre
]
private static let managedFrames = Set(standardFrames.keys)
private static let managedUserTexts: [String: WritableKeyPath<TrackMetadata, String>] = [
"Acoustid Id": \.acoustID,
"MusicBrainz Recording Id": \.musicBrainzRecordingID,
"MusicBrainz Album Id": \.musicBrainzReleaseID
]
private static let v22FrameIDs: [String: String] = [
"BUF": "RBUF", "CNT": "PCNT", "COM": "COMM", "CRA": "AENC", "ETC": "ETCO", "GEO": "GEOB",
"IPL": "TIPL", "MCI": "MCDI", "MLL": "MLLT", "PIC": "APIC", "POP": "POPM", "REV": "RVRB",
"RVA": "RVAD", "SLT": "SYLT", "STC": "SYTC", "TAL": "TALB", "TBP": "TBPM", "TCM": "TCOM",
"TCO": "TCON", "TCR": "TCOP", "TDA": "TDAT", "TDY": "TDLY", "TEN": "TENC", "TFT": "TFLT",
"TIM": "TIME", "TKE": "TKEY", "TLA": "TLAN", "TLE": "TLEN", "TMT": "TMED", "TOA": "TOPE",
"TOF": "TOFN", "TOL": "TOLY", "TOR": "TORY", "TOT": "TOAL", "TP1": "TPE1", "TP2": "TPE2",
"TP3": "TPE3", "TP4": "TPE4", "TPA": "TPOS", "TPB": "TPUB", "TRC": "TSRC", "TRD": "TRDA",
"TRK": "TRCK", "TSI": "TSIZ", "TSS": "TSSE", "TT1": "TIT1", "TT2": "TIT2", "TT3": "TIT3",
"TXT": "TEXT", "TXX": "TXXX", "TYE": "TYER", "UFI": "UFID", "ULT": "USLT", "WAF": "WOAF",
"WAM": "WOAM", "WAS": "WOAS", "WCM": "WCOM", "WCP": "WCOP", "WPB": "WPUB", "WXX": "WXXX"
]
static func read(_ url: URL) -> TrackMetadata {
guard let handle = try? FileHandle(forReadingFrom: url) else { return TrackMetadata() }
defer { try? handle.close() }
guard let header = try? handle.read(upToCount: 10), header.count == 10,
String(data: header.prefix(3), encoding: .ascii) == "ID3" else { return TrackMetadata() }
let tagSize = syncSafe(header[6..<10])
guard tagSize > 0, let tagBody = try? handle.read(upToCount: tagSize) else { return TrackMetadata() }
var data = header
data.append(tagBody)
let version = header[3]
let limit = min(data.count, 10 + syncSafe(data[6..<10]))
var position = 10
var result = TrackMetadata()
if version == 2 {
guard header[5] & 0x40 == 0 else { return result }
while position + 6 <= limit {
guard let oldID = String(data: data[position..<position + 3], encoding: .ascii), oldID.utf8.allSatisfy({ $0 >= 0x20 && $0 <= 0x5A }), oldID != "\0\0\0" else { break }
let size = int24(data[position + 3..<position + 6]); position += 6
guard size > 0, position + size <= limit else { break }
let id = v22FrameIDs[oldID] ?? oldID
let frame = data[position..<position + size]
if let keyPath = standardFrames[id], let value = decodeText(frame) { result[keyPath: keyPath] = id == "TCON" ? normalizeGenre(value) : value }
if id == "TXXX", let (description, value) = decodeUserText(frame), let keyPath = managedUserTexts[description] { result[keyPath: keyPath] = value }
position += size
}
} else {
while position + 10 <= limit {
guard let id = String(data: data[position..<position + 4], encoding: .ascii), id.utf8.allSatisfy({ $0 >= 0x20 && $0 <= 0x5A }), id != "\0\0\0\0" else { break }
let size = version == 4 ? syncSafe(data[position + 4..<position + 8]) : int32(data[position + 4..<position + 8]); position += 10
guard size > 0, position + size <= limit else { break }
let frame = data[position..<position + size]
if let keyPath = standardFrames[id], let value = decodeText(frame) { result[keyPath: keyPath] = id == "TCON" ? normalizeGenre(value) : value }
if id == "TXXX", let (description, value) = decodeUserText(frame), let keyPath = managedUserTexts[description] { result[keyPath: keyPath] = value }
position += size
}
}
return result
}
static func write(_ metadata: TrackMetadata, to url: URL) throws {
let original = try Data(contentsOf: url)
var audio = original
var frames = Data()
if original.count >= 10, String(data: original.prefix(3), encoding: .ascii) == "ID3" {
let version = original[3]
let end = min(original.count, 10 + syncSafe(original[6..<10]))
audio = original.subdata(in: end..<original.count)
if version == 2 {
guard original[5] & 0x40 == 0 else { throw ID3MetadataError.unsupportedCompressedV22 }
var index = 10
while index + 6 <= end {
guard let oldID = String(data: original[index..<index + 3], encoding: .ascii), oldID.utf8.allSatisfy({ $0 >= 0x20 && $0 <= 0x5A }), oldID != "\0\0\0" else { break }
let size = int24(original[index + 3..<index + 6]); index += 6
guard size > 0, index + size <= end else { break }
let oldFrame = original[index..<index + size]
if let frameID = v22FrameIDs[oldID] {
let frame = frameID == "APIC" ? convertV22Picture(oldFrame) ?? Data() : Data(oldFrame)
let shouldReplace = managedFrames.contains(frameID) || (frameID == "TXXX" && decodeUserText(frame).map { managedUserTexts[$0.0] != nil } == true)
if !shouldReplace, !frame.isEmpty { appendFrame(id: frameID, content: frame, to: &frames) }
}
index += size
}
} else {
var index = 10
while index + 10 <= end {
guard let frameID = String(data: original[index..<index + 4], encoding: .ascii) else { break }
let size = version == 4 ? syncSafe(original[index + 4..<index + 8]) : int32(original[index + 4..<index + 8])
guard size > 0, index + 10 + size <= end else { break }
let frame = original[index + 10..<index + 10 + size]
let shouldReplace = managedFrames.contains(frameID) || (frameID == "TXXX" && decodeUserText(frame).map { managedUserTexts[$0.0] != nil } == true)
if !shouldReplace { appendFrame(id: frameID, content: Data(frame), to: &frames) }
index += 10 + size
}
}
}
for (id, keyPath) in standardFrames {
let value = metadata[keyPath: keyPath]
guard !value.isEmpty else { continue }
appendFrame(id: id, content: encodeV23Text(value), to: &frames)
}
for (description, keyPath) in managedUserTexts {
let value = metadata[keyPath: keyPath]
guard !value.isEmpty else { continue }
let content = Data([1, 0xFF, 0xFE]) + utf16LE(description) + Data([0, 0, 0xFF, 0xFE]) + utf16LE(value)
appendFrame(id: "TXXX", content: content, to: &frames)
}
var tag = Data("ID3".utf8)
tag.append(contentsOf: [3, 0, 0]); tag.append(contentsOf: syncSafeBytes(frames.count)); tag.append(frames); tag.append(audio)
try tag.write(to: url, options: .atomic)
}
static func verify(_ expected: TrackMetadata, at url: URL) throws {
let actual = read(url)
guard actual.title == expected.title,
actual.artist == expected.artist,
actual.album == expected.album,
actual.albumArtist == expected.albumArtist,
actual.trackNumber == expected.trackNumber,
actual.discNumber == expected.discNumber,
actual.year == expected.year,
actual.genre == expected.genre,
actual.acoustID == expected.acoustID,
actual.musicBrainzRecordingID == expected.musicBrainzRecordingID,
actual.musicBrainzReleaseID == expected.musicBrainzReleaseID else {
throw ID3MetadataError.verificationFailed(url.lastPathComponent)
}
}
private static func decodeText<S: DataProtocol>(_ data: S) -> String? {
guard let encoding = data.first else { return nil }
let value = Data(data.dropFirst())
let decoded: String?
switch encoding {
case 0: decoded = String(data: value, encoding: .isoLatin1)
case 1: decoded = String(data: value, encoding: .utf16)
case 2: decoded = String(data: value, encoding: .utf16BigEndian)
default: decoded = String(data: value, encoding: .utf8)
}
return decoded?.trimmingCharacters(in: .controlCharacters)
}
private static func decodeUserText<S: DataProtocol>(_ data: S) -> (String, String)? {
guard let encoding = data.first else { return nil }
let value = Data(data.dropFirst())
let separatorStart: Int
let separatorLength: Int
if encoding == 1 || encoding == 2 {
let start = value.starts(with: [0xFF, 0xFE]) || value.starts(with: [0xFE, 0xFF]) ? 2 : 0
var cursor = start
var found: Int?
while cursor + 1 < value.count {
if value[cursor] == 0 && value[cursor + 1] == 0 { found = cursor; break }
cursor += 2
}
guard let found else { return nil }
separatorStart = found; separatorLength = 2
} else {
guard let found = value.firstIndex(of: 0) else { return nil }
separatorStart = found; separatorLength = 1
}
let description = decodeEncoded(value[..<separatorStart], encoding: encoding)
let text = decodeEncoded(value[(separatorStart + separatorLength)...], encoding: encoding)
return (description, text)
}
private static func decodeEncoded<S: DataProtocol>(_ data: S, encoding: UInt8) -> String {
let value = Data(data)
switch encoding {
case 0: return String(data: value, encoding: .isoLatin1) ?? ""
case 1: return String(data: value, encoding: .utf16) ?? ""
case 2: return String(data: value, encoding: .utf16BigEndian) ?? ""
default: return String(data: value, encoding: .utf8) ?? ""
}
}
private static func encodeV23Text(_ value: String) -> Data { Data([1, 0xFF, 0xFE]) + utf16LE(value) }
private static func normalizeGenre(_ value: String) -> String {
let genres = [
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Hip-Hop", "Jazz", "Metal",
"New Age", "Oldies", "Other", "Pop", "R&B", "Rap", "Reggae", "Rock", "Techno", "Industrial",
"Alternative", "Ska", "Death Metal", "Pranks", "Soundtrack", "Euro-Techno", "Ambient", "Trip-Hop", "Vocal", "Jazz+Funk",
"Fusion", "Trance", "Classical", "Instrumental", "Acid", "House", "Game", "Sound Clip", "Gospel", "Noise",
"AlternRock", "Bass", "Soul", "Punk", "Space", "Meditative", "Instrumental Pop", "Instrumental Rock", "Ethnic", "Gothic",
"Darkwave", "Techno-Industrial", "Electronic", "Pop-Folk", "Eurodance", "Dream", "Southern Rock", "Comedy", "Cult", "Gangsta",
"Top 40", "Christian Rap", "Pop/Funk", "Jungle", "Native American", "Cabaret", "New Wave", "Psychedelic", "Rave", "Showtunes",
"Trailer", "Lo-Fi", "Tribal", "Acid Punk", "Acid Jazz", "Polka", "Retro", "Musical", "Rock & Roll", "Hard Rock"
]
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
let pattern = #"^\((\d{1,3})\)\s*"#
if let range = trimmed.range(of: pattern, options: .regularExpression),
let numberRange = trimmed.range(of: #"\d{1,3}"#, options: .regularExpression),
let index = Int(trimmed[numberRange]), genres.indices.contains(index) {
let suffix = String(trimmed[range.upperBound...]).trimmingCharacters(in: .whitespaces)
return suffix.isEmpty ? genres[index] : suffix
}
if let index = Int(trimmed), genres.indices.contains(index) { return genres[index] }
return value
}
private static func convertV22Picture<S: DataProtocol>(_ frame: S) -> Data? {
let bytes = Data(frame)
guard bytes.count > 5, let format = String(data: bytes[1..<4], encoding: .ascii) else { return nil }
let normalized = format.uppercased()
let mime = normalized == "JPG" || normalized == "JPEG" ? "image/jpeg" : "image/\(normalized.lowercased())"
return Data([bytes[0]]) + Data(mime.utf8) + Data([0]) + bytes[4...]
}
private static func utf16LE(_ value: String) -> Data {
var data = Data(capacity: value.utf16.count * 2)
for unit in value.utf16 { data.append(UInt8(unit & 0xFF)); data.append(UInt8(unit >> 8)) }
return data
}
private static func appendFrame(id: String, content: Data, to frames: inout Data) {
frames.append(Data(id.utf8)); frames.append(contentsOf: [UInt8((content.count >> 24) & 255), UInt8((content.count >> 16) & 255), UInt8((content.count >> 8) & 255), UInt8(content.count & 255), 0, 0]); frames.append(content)
}
private static func int32<S: Collection>(_ bytes: S) -> Int where S.Element == UInt8 { bytes.reduce(0) { ($0 << 8) | Int($1) } }
private static func int24<S: Collection>(_ bytes: S) -> Int where S.Element == UInt8 { bytes.reduce(0) { ($0 << 8) | Int($1) } }
private static func syncSafe<S: Collection>(_ bytes: S) -> Int where S.Element == UInt8 { bytes.reduce(0) { ($0 << 7) | Int($1 & 0x7f) } }
private static func syncSafeBytes(_ number: Int) -> [UInt8] { [UInt8((number >> 21) & 0x7f), UInt8((number >> 14) & 0x7f), UInt8((number >> 7) & 0x7f), UInt8(number & 0x7f)] }
}
enum ID3MetadataError: LocalizedError {
case verificationFailed(String)
case unsupportedCompressedV22
var errorDescription: String? {
switch self {
case .verificationFailed(let file): "Die geschriebenen Tags in \(file) konnten nicht überprüft werden."
case .unsupportedCompressedV22: "Diese komprimierten ID3v2.2-Tags können nicht sicher bearbeitet werden. Die Datei wurde nicht geändert."
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,63 @@
import Foundation
enum MediaToolLocator {
static func executable(named name: String) -> URL? {
guard let bundled = Bundle.main.resourceURL?.appendingPathComponent(name),
FileManager.default.isExecutableFile(atPath: bundled.path) else {
return nil
}
return bundled
}
static func javascriptRuntimeArguments() -> [String] {
guard let runtime = executable(named: "deno") else { return [] }
return ["--js-runtimes", "deno:\(runtime.path)"]
}
}
enum MediaToolError: LocalizedError {
case missing(String)
case failed(String, String)
var errorDescription: String? {
switch self {
case .missing(let tool): "\(tool) fehlt im App-Paket. Installiere eine App-Version mit gebündelten Werkzeugen."
case .failed(let tool, let detail): "\(tool) ist fehlgeschlagen: \(detail)"
}
}
}
enum MediaProcess {
static func run(_ executable: URL, arguments: [String]) throws -> Data {
let id = UUID().uuidString
let outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("tonuino-tool-\(id).out")
let errorURL = FileManager.default.temporaryDirectory.appendingPathComponent("tonuino-tool-\(id).err")
FileManager.default.createFile(atPath: outputURL.path, contents: nil)
FileManager.default.createFile(atPath: errorURL.path, contents: nil)
defer {
try? FileManager.default.removeItem(at: outputURL)
try? FileManager.default.removeItem(at: errorURL)
}
let outputHandle = try FileHandle(forWritingTo: outputURL)
let errorHandle = try FileHandle(forWritingTo: errorURL)
defer { try? outputHandle.close(); try? errorHandle.close() }
let process = Process()
process.executableURL = executable
process.arguments = arguments
process.standardOutput = outputHandle
process.standardError = errorHandle
do { try process.run() }
catch { throw MediaToolError.failed(executable.lastPathComponent, error.localizedDescription) }
process.waitUntilExit()
let output = try Data(contentsOf: outputURL)
let errorOutput = String(data: try Data(contentsOf: errorURL), encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard process.terminationStatus == 0 else {
let detail = errorOutput.isEmpty ? "Prozesscode \(process.terminationStatus)" : String(errorOutput.suffix(1800))
throw MediaToolError.failed(executable.lastPathComponent, detail)
}
return output
}
}
@@ -0,0 +1,92 @@
import Foundation
struct MusicBrainzRelease: Decodable, Identifiable, Hashable {
let id: String
let title: String
let date: String?
let artistCredit: [ArtistCredit]?
let media: [Medium]?
let genres: [Genre]?
var artist: String { artistCredit?.map { ($0.name ?? $0.artist?.name ?? "") + ($0.joinphrase ?? "") }.joined() ?? "" }
var year: String { String((date ?? "").prefix(4)) }
var genre: String { genres?.map(\.name).joined(separator: ", ") ?? "" }
var tracks: [MusicBrainzRecording] { media?.flatMap { medium in (medium.tracks ?? []).map { $0.withDisc(medium.position.map(String.init) ?? medium.number ?? "1") } } ?? [] }
enum CodingKeys: String, CodingKey { case id, title, date, media, genres; case artistCredit = "artist-credit" }
}
struct MusicBrainzRecording: Decodable, Identifiable, Hashable {
let recording: RecordingID?
let title: String
let number: String?
let position: Int?
let length: Int?
let artistCredit: [ArtistCredit]?
var id: String { recording?.id ?? "\(discNumber)-\(displayNumber)-\(title)" }
private(set) var discNumber = "1"
enum CodingKeys: String, CodingKey { case recording, title, number, position, length; case artistCredit = "artist-credit" }
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
recording = try values.decodeIfPresent(RecordingID.self, forKey: .recording)
title = try values.decode(String.self, forKey: .title)
number = try values.decodeIfPresent(String.self, forKey: .number)
position = try values.decodeIfPresent(Int.self, forKey: .position)
length = try values.decodeIfPresent(Int.self, forKey: .length)
artistCredit = try values.decodeIfPresent([ArtistCredit].self, forKey: .artistCredit)
}
var artist: String { artistCredit?.map { ($0.name ?? $0.artist?.name ?? "") + ($0.joinphrase ?? "") }.joined() ?? "" }
var displayNumber: String { number ?? position.map(String.init) ?? "—" }
func withDisc(_ value: String) -> MusicBrainzRecording { var copy = self; copy.discNumber = value; return copy }
}
struct RecordingID: Decodable, Hashable { let id: String? }
struct ArtistCredit: Decodable, Hashable {
let name: String?
let artist: CreditArtist?
let joinphrase: String?
}
struct CreditArtist: Decodable, Hashable { let name: String? }
struct Medium: Decodable, Hashable { let position: Int?; let number: String?; let tracks: [MusicBrainzRecording]? }
struct Genre: Decodable, Hashable { let name: String }
enum MusicBrainzService {
private static let base = URL(string: "https://musicbrainz.org/ws/2")!
private static let session: URLSession = {
let configuration = URLSessionConfiguration.ephemeral
configuration.timeoutIntervalForRequest = 25
configuration.httpAdditionalHeaders = ["User-Agent": "TonuinoManager/1.0 (macOS music library manager)"]
return URLSession(configuration: configuration)
}()
static func searchReleases(artist: String, album: String) async throws -> [MusicBrainzRelease] {
await RateGate.shared.wait()
var components = URLComponents(url: base.appendingPathComponent("release"), resolvingAgainstBaseURL: false)!
let query = [album.isEmpty ? nil : "release:\"\(escape(album))\"", artist.isEmpty ? nil : "artist:\"\(escape(artist))\""].compactMap { $0 }.joined(separator: " AND ")
components.queryItems = [URLQueryItem(name: "query", value: query.isEmpty ? "*" : query), URLQueryItem(name: "limit", value: "15"), URLQueryItem(name: "fmt", value: "json")]
let (data, _) = try await session.data(from: components.url!)
return try JSONDecoder().decode(ReleaseSearch.self, from: data).releases
}
static func loadRelease(id: String) async throws -> MusicBrainzRelease {
await RateGate.shared.wait()
var components = URLComponents(url: base.appendingPathComponent("release/\(id)"), resolvingAgainstBaseURL: false)!
components.queryItems = [URLQueryItem(name: "inc", value: "recordings+artist-credits+genres"), URLQueryItem(name: "fmt", value: "json")]
let (data, _) = try await session.data(from: components.url!)
return try JSONDecoder().decode(MusicBrainzRelease.self, from: data)
}
private static func escape(_ value: String) -> String { value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") }
private struct ReleaseSearch: Decodable { let releases: [MusicBrainzRelease] }
private actor RateGate {
static let shared = RateGate()
private var lastRequest = Date.distantPast
func wait() async {
let delay = max(0, 1.1 - Date().timeIntervalSince(lastRequest))
lastRequest = Date().addingTimeInterval(delay)
if delay > 0 { try? await Task.sleep(for: .seconds(delay)) }
}
}
}
@@ -0,0 +1,161 @@
import CryptoTokenKit
import Combine
import Foundation
@MainActor
final class CardModel: ObservableObject {
@Published private(set) var readerNames: [String] = []
@Published var selectedReader = ""
@Published private(set) var cardStatus = "Kein Reader verbunden"
@Published private(set) var currentUID = ""
@Published private(set) var currentCard: TonuinoCard?
@Published private(set) var serialPorts = TonuinoSerialService.ports
@Published var selectedSerialPort = ""
@Published private(set) var serialConnected = false
@Published private(set) var firmwareVersion: String?
@Published private(set) var tonuinoSettings: TonuinoDeviceSettings?
@Published var writing = false
@Published var configuringSettings = false
@Published private(set) var serialAdminMenuActive = false
private let serial = TonuinoSerialService()
private let reader = PCSCReader()
func refreshReaders() { readerNames = reader.slotNames; if !readerNames.contains(selectedReader) { selectedReader = readerNames.first ?? "" } }
func connectSerial() async throws {
guard !selectedSerialPort.isEmpty else { throw SerialError.disconnected }
let port = selectedSerialPort
let service = serial
let connection = try await Task.detached(priority: .userInitiated) { try service.connect(port) }.value
firmwareVersion = connection.firmwareVersion
tonuinoSettings = connection.settings
serialConnected = true
}
func disconnectSerial() { serial.disconnect(); serialConnected = false; firmwareVersion = nil; tonuinoSettings = nil; serialAdminMenuActive = false }
func refreshSerialPorts() { serialPorts = TonuinoSerialService.ports; if !serialPorts.contains(selectedSerialPort) { selectedSerialPort = serialPorts.first ?? "" } }
func writeViaTonuino(_ card: TonuinoCard) async throws {
guard serialConnected else { throw SerialError.disconnected }; writing = true
defer { writing = false }
let service = serial
try await Task.detached(priority: .userInitiated) { try await service.writeCard(card) }.value
serialAdminMenuActive = true
}
func cancelWrite() throws { try serial.cancelWrite(); serialAdminMenuActive = false }
func exitTonuinoAdminMenu() throws { try serial.exitAdminMenu(); serialAdminMenuActive = false }
func applyTonuinoSetting(menuItem: Int, option: Int, currentPIN: String = "", newPIN: String = "") async throws {
guard serialConnected else { throw SerialError.disconnected }
configuringSettings = true
defer { configuringSettings = false }
let service = serial
try await Task.detached(priority: .userInitiated) {
try await service.applyAdminSetting(menuItem: menuItem, option: option, currentPIN: currentPIN, newPIN: newPIN)
}.value
serialAdminMenuActive = true
}
func applyTonuinoVolume(minimum: Int, maximum: Int, initial: Int, currentPIN: String = "") async throws {
guard serialConnected else { throw SerialError.disconnected }
configuringSettings = true
defer { configuringSettings = false }
let service = serial
try await Task.detached(priority: .userInitiated) {
try await service.applyVolumeSettings(minimum: minimum, maximum: maximum, initial: initial, currentPIN: currentPIN)
}.value
serialAdminMenuActive = true
}
func resetTonuinoSettings(currentPIN: String = "") async throws {
guard serialConnected else { throw SerialError.disconnected }
configuringSettings = true
defer { configuringSettings = false }
let service = serial
try await Task.detached(priority: .userInitiated) {
try await service.resetAdminSettings(currentPIN: currentPIN)
}.value
serialAdminMenuActive = false
}
func readViaReader() async throws -> (String, TonuinoCard?) { try await reader.readCard(slotName: selectedReader) }
func writeViaReader(_ card: TonuinoCard) async throws { try await reader.writeCard(card, slotName: selectedReader) }
func eraseViaReader() async throws { try await reader.writeCard(nil, slotName: selectedReader) }
}
final class PCSCReader {
private var manager: TKSmartCardSlotManager? { TKSmartCardSlotManager.default }
var slotNames: [String] { manager?.slotNames ?? [] }
func readCard(slotName: String) async throws -> (String, TonuinoCard?) {
let (card, _) = try await connectedCard(slotName)
defer { card.endSession() }
let uidData = try await transmit(card, [0xFF, 0xCA, 0x00, 0x00, 0x00])
let uid = uidData.map { String(format: "%02X", $0) }.joined()
let bytes = try await readTonuinoData(card)
return (uid, TonuinoCard.decode(bytes))
}
func writeCard(_ value: TonuinoCard?, slotName: String) async throws {
let (card, atr) = try await connectedCard(slotName)
defer { card.endSession() }
let type = CardType(atr: atr)
guard type != .unknown else { throw PCSCError.unsupportedCard }
let payload = value?.payload() ?? Data(repeating: 0, count: 16)
if type == .ultralight {
for page in 0..<4 {
let chunk = payload.subdata(in: page * 4..<(page + 1) * 4)
_ = try await transmit(card, [0xFF, 0xD6, 0x00, UInt8(8 + page), 0x04] + chunk)
}
} else {
try await authenticate(card, block: 4)
_ = try await transmit(card, [0xFF, 0xD6, 0x00, 0x04, 0x10] + payload)
}
}
private func readTonuinoData(_ card: TKSmartCard) async throws -> Data {
let atr = Array(card.slot.atr?.bytes ?? Data())
if CardType(atr: atr) == .ultralight {
return try await transmit(card, [0xFF, 0xB0, 0x00, 0x08, 0x10])
}
try await authenticate(card, block: 4)
return try await transmit(card, [0xFF, 0xB0, 0x00, 0x04, 0x10])
}
private func authenticate(_ card: TKSmartCard, block: UInt8) async throws {
_ = try await transmit(card, [0xFF, 0x82, 0x00, 0x00, 0x06, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF])
_ = try await transmit(card, [0xFF, 0x86, 0x00, 0x00, 0x05, 0x01, 0x00, block, 0x60, 0x00])
}
private func connectedCard(_ name: String) async throws -> (TKSmartCard, [UInt8]) {
guard let slot = manager?.slotNamed(name), let card = slot.makeSmartCard() else { throw PCSCError.noCard }
try await card.beginSession()
return (card, Array(slot.atr?.bytes ?? Data()))
}
private func transmit(_ card: TKSmartCard, _ command: [UInt8]) async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
card.transmit(Data(command)) { response, error in
if let error { continuation.resume(throwing: error); return }
guard let response, response.count >= 2 else { continuation.resume(throwing: PCSCError.communication); return }
let status = response.suffix(2)
guard status.elementsEqual([0x90, 0x00]) else { continuation.resume(throwing: PCSCError.status(UInt8(status.first ?? 0), UInt8(status.last ?? 0))); return }
continuation.resume(returning: response.dropLast(2))
}
}
}
}
enum CardType: Equatable {
case classic, ultralight, unknown
init(atr: [UInt8]) {
let marker: [UInt8] = [0xA0, 0x00, 0x00, 0x03, 0x06, 0x03, 0x00]
guard let i = atr.indices.dropLast(marker.count).first(where: { Array(atr[$0..<$0 + marker.count]) == marker }), atr.count > i + marker.count else { self = .unknown; return }
switch atr[i + marker.count] { case 0x01, 0x02, 0x26: self = .classic; case 0x03: self = .ultralight; default: self = .unknown }
}
}
enum PCSCError: LocalizedError {
case noCard, unsupportedCard, communication, status(UInt8, UInt8)
var errorDescription: String? {
switch self {
case .noCard: "Lege eine Karte auf den ausgewählten Reader."
case .unsupportedCard: "Dieser Kartentyp wird nicht unterstützt."
case .communication: "Keine gültige Antwort vom Kartenleser."
case .status(let a, let b): "Kartenleser meldet Fehlerstatus %02X%02X.".replacingOccurrences(of: "%02X%02X", with: String(format: "%02X%02X", a, b))
}
}
}
@@ -0,0 +1,303 @@
import Darwin
import Foundation
final class TonuinoSerialService: @unchecked Sendable {
private let lock = NSLock()
private var descriptor: Int32 = -1
private var buffer = Data()
private var outputMonitor: Task<Void, Never>?
private(set) var firmwareVersion: String?
static var ports: [String] {
((try? FileManager.default.contentsOfDirectory(atPath: "/dev")) ?? [])
.filter { $0.hasPrefix("cu.") }.map { "/dev/\($0)" }.sorted()
}
var isConnected: Bool { lock.lock(); defer { lock.unlock() }; return descriptor >= 0 }
func connect(_ path: String) throws -> (firmwareVersion: String?, settings: TonuinoDeviceSettings?) {
disconnect()
let fd = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK)
guard fd >= 0 else { throw SerialError.open(path, String(cString: strerror(errno))) }
var termiosConfig = termios()
guard tcgetattr(fd, &termiosConfig) == 0 else { close(fd); throw SerialError.configure }
cfmakeraw(&termiosConfig); cfsetispeed(&termiosConfig, speed_t(B115200)); cfsetospeed(&termiosConfig, speed_t(B115200))
termiosConfig.c_cflag |= tcflag_t(CLOCAL | CREAD)
termiosConfig.c_cflag &= ~tcflag_t(CSTOPB | PARENB | CSIZE)
termiosConfig.c_cflag |= tcflag_t(CS8)
guard tcsetattr(fd, TCSANOW, &termiosConfig) == 0 else { close(fd); throw SerialError.configure }
tcflush(fd, TCIOFLUSH)
lock.lock(); descriptor = fd; buffer.removeAll(); lock.unlock()
firmwareVersion = nil
var parsedSettings = TonuinoDeviceSettings()
let startupDeadline = Date().addingTimeInterval(3) // many TonUINO boards reset when serial is opened
while Date() < startupDeadline {
let remaining = startupDeadline.timeIntervalSinceNow
guard let line = try readLine(timeout: min(0.15, max(remaining, 0))) else { continue }
if let range = line.range(of: #"V\d+\.\d+\.\d+[^\r\n]*"#, options: .regularExpression) {
firmwareVersion = String(line[range])
}
parsedSettings.readLogLine(line)
}
let capturedSettings = parsedSettings.hasValues ? parsedSettings : nil
outputMonitor = Task.detached(priority: .utility) { [weak self] in
while !Task.isCancelled {
guard let self else { return }
do { _ = try self.readLine(timeout: 0.3) }
catch { return }
}
}
return (firmwareVersion, capturedSettings)
}
func disconnect() {
outputMonitor?.cancel()
outputMonitor = nil
lock.lock(); let old = descriptor; descriptor = -1; buffer.removeAll(); lock.unlock()
if old >= 0 { close(old) }
}
func writeCard(_ card: TonuinoCard) async throws {
try card.validate()
guard card.modifier == nil else {
throw SerialError.invalid("Sonderkarten können mit diesem seriellen Schreibassistenten nicht programmiert werden. Verwende einen PC/SC-Reader oder exportiere einen Dump.")
}
// TonUINO-TNG has no WRITECARD serial command. With SerialInputAsCommand
// enabled, its documented numeric commands can drive the built-in
// Admin > New card voice menu instead.
try write("-4\n") // allLong: open the admin menu while TonUINO is idle
try await Task.sleep(for: .milliseconds(500))
try await selectMenuItem(1) // Admin: create new card
let mode = card.mode == .admin ? 6 : card.mode.rawValue
try await selectMenuItem(mode)
if card.mode.needsFolder, ![.admin, .repeatLast, .bluetooth].contains(card.mode) {
try await selectMenuItem(Int(card.folder))
}
switch card.mode {
case .single:
try await selectMenuItem(Int(card.special))
case .hoerspielRange, .albumRange, .partyRange, .audiobookRange:
try await selectMenuItem(Int(card.special))
try await selectMenuItem(Int(card.special2))
case .audiobook:
try await selectMenuItem(Int(card.folder) + Int(card.special2))
case .audiobookSingle:
try await selectMenuItem(Int(card.folder) + Int(card.special2))
try await selectMenuItem(Int(card.special) + 1)
case .quiz:
let option: Int
switch (card.special, card.special2) {
case (2, 0): option = 1
case (4, 0): option = 2
case (2, 1): option = 3
case (4, 1): option = 4
case (0, 1): option = 5
default: throw SerialError.invalid("Ungültige Quiz-Optionen.")
}
try await selectMenuItem(option)
default:
break
}
}
func cancelWrite() throws { try exitAdminMenu() }
func exitAdminMenu() throws { try write("-6\n") }
func applyAdminSetting(menuItem: Int, option: Int, currentPIN: String = "", newPIN: String = "") async throws {
guard (2...14).contains(menuItem), (1...99).contains(option) else {
throw SerialError.invalid("Ungültige TonUINO-Menüauswahl.")
}
if menuItem == 12, option == 3 { try validatePIN(newPIN) }
try await enterAdmin(currentPIN: currentPIN)
try await selectMenuItem(menuItem)
try await selectMenuItem(option)
if menuItem == 12, option == 3 {
try await sendPIN(newPIN)
}
}
func applyVolumeSettings(minimum: Int, maximum: Int, initial: Int, currentPIN: String = "") async throws {
guard (1...29).contains(minimum), (2...30).contains(maximum), minimum < maximum,
(minimum...maximum).contains(initial) else {
throw SerialError.invalid("Lautstärke: Minimum muss unter dem Maximum liegen; Startlautstärke muss dazwischen liegen.")
}
try await enterAdmin(currentPIN: currentPIN)
// Set a known intermediate range so subsequent voice-menu indices are deterministic.
try await selectMenuItem(3) // Minimum volume
try await selectMenuItem(1)
try await selectMenuItem(2) // Maximum volume
try await selectMenuItem(29) // 30, now that minimum is 1
try await selectMenuItem(3)
try await selectMenuItem(minimum)
try await selectMenuItem(2)
try await selectMenuItem(maximum - minimum)
try await selectMenuItem(4)
try await selectMenuItem(initial - minimum + 1)
}
func resetAdminSettings(currentPIN: String = "") async throws {
try await enterAdmin(currentPIN: currentPIN)
try await selectMenuItem(11)
// The reset state consumes one command to return to Admin_Entry.
try await Task.sleep(for: .milliseconds(250))
try write("-6\n")
try await Task.sleep(for: .milliseconds(250))
try write("-6\n")
try await Task.sleep(for: .milliseconds(250))
}
private func enterAdmin(currentPIN: String) async throws {
if !currentPIN.isEmpty { try validatePIN(currentPIN) }
try write("-4\n") // allLong: enter Admin while idle
try await Task.sleep(for: .milliseconds(500))
if !currentPIN.isEmpty { try await sendPIN(currentPIN) }
}
private func sendPIN(_ pin: String) async throws {
let commands: [Character: Int] = ["1": -5, "2": -8, "3": -2]
for digit in pin {
try Task.checkCancellation()
try write("\(commands[digit]!)\n")
try await Task.sleep(for: .milliseconds(220))
}
try await Task.sleep(for: .milliseconds(250))
}
private func validatePIN(_ pin: String) throws {
guard pin.count == 4, pin.allSatisfy({ ["1", "2", "3"].contains(String($0)) }) else {
throw SerialError.invalid("Die TonUINO-PIN muss aus vier Ziffern 1, 2 oder 3 bestehen.")
}
}
private func selectMenuItem(_ value: Int) async throws {
try Task.checkCancellation()
try write("\(value)\n")
try await Task.sleep(for: .milliseconds(180))
try write("-5\n") // pause: select the current voice-menu item
try await Task.sleep(for: .milliseconds(250))
}
private func write(_ text: String) throws {
lock.lock(); defer { lock.unlock() }
guard descriptor >= 0 else { throw SerialError.disconnected }
let bytes = Array(text.utf8)
let result = Darwin.write(descriptor, bytes, bytes.count)
guard result == bytes.count else { throw SerialError.io(String(cString: strerror(errno))) }
}
private func readLine(timeout: TimeInterval) throws -> String? {
let deadline = Date().addingTimeInterval(timeout)
while Date() < deadline {
lock.lock(); let fd = descriptor; lock.unlock()
guard fd >= 0 else { throw SerialError.disconnected }
var pollDescriptor = pollfd(fd: fd, events: Int16(POLLIN), revents: 0)
if Darwin.poll(&pollDescriptor, 1, 100) > 0 {
var bytes = [UInt8](repeating: 0, count: 256)
let count = Darwin.read(fd, &bytes, bytes.count)
if count > 0 {
lock.lock(); buffer.append(contentsOf: bytes.prefix(count))
if let newline = buffer.firstIndex(of: 10) {
let line = Data(buffer[..<newline]); buffer.removeSubrange(...newline); lock.unlock()
return String(data: line, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
}
lock.unlock()
}
}
}
return nil
}
}
/// Values printed by current TonUINO-TNG AiO Plus firmware during startup.
/// Older AiO firmware may not print these lines, so every field is optional.
struct TonuinoDeviceSettings: Equatable {
var firmwareSettingsVersion: Int?
var speakerMaximum: Int?
var speakerMinimum: Int?
var speakerInitial: Int?
var headphoneMaximum: Int?
var headphoneMinimum: Int?
var headphoneInitial: Int?
var equalizer: Int?
var standbyTimer: Int?
var invertedVolumeButtons: Int?
var adminMenuLocked: Int?
var pauseWhenCardRemoved: Int?
var hasValues: Bool {
firmwareSettingsVersion != nil || speakerMaximum != nil || speakerMinimum != nil || speakerInitial != nil ||
headphoneMaximum != nil || headphoneMinimum != nil || headphoneInitial != nil || equalizer != nil ||
standbyTimer != nil || invertedVolumeButtons != nil || adminMenuLocked != nil || pauseWhenCardRemoved != nil
}
mutating func readLogLine(_ line: String) {
if let values = values(after: "Ver:", in: line), let first = values.first { firmwareSettingsVersion = first }
if let values = values(after: "Vol:", in: line) {
if values.indices.contains(0) { speakerMaximum = values[0] }
if values.indices.contains(1) { speakerMinimum = values[1] }
if values.indices.contains(2) { speakerInitial = values[2] }
if values.indices.contains(3) { headphoneMaximum = values[3] }
if values.indices.contains(4) { headphoneMinimum = values[4] }
if values.indices.contains(5) { headphoneInitial = values[5] }
}
if let values = values(after: "EQ:", in: line), let first = values.first { equalizer = first }
if let values = values(after: "ST:", in: line), let first = values.first { standbyTimer = first }
if let values = values(after: "IB:", in: line), let first = values.first { invertedVolumeButtons = first }
if let values = values(after: "AL:", in: line), let first = values.first { adminMenuLocked = first }
if let values = values(after: "PCR:", in: line), let first = values.first { pauseWhenCardRemoved = first }
// AP (the admin PIN) is intentionally neither parsed nor retained.
}
private func values(after marker: String, in line: String) -> [Int]? {
guard let range = line.range(of: NSRegularExpression.escapedPattern(for: marker), options: [.regularExpression, .caseInsensitive]) else { return nil }
let remainder = line[range.upperBound...]
let text = String(remainder)
let pattern = #"-?\d+"#
guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
let nsRange = NSRange(text.startIndex..., in: text)
return regex.matches(in: text, range: nsRange).compactMap { match in
guard let matchRange = Range(match.range, in: text) else { return nil }
return Int(text[matchRange])
}
}
}
extension TonuinoCard {
func validate() throws {
if let modifier {
switch modifier {
case .sleepTimer:
guard special > 0 else { throw SerialError.invalid("Der Einschlaf-Timer muss mindestens eine Minute dauern.") }
case .freezeDance, .fireWaterAir:
guard special <= 2 else { throw SerialError.invalid("Die Pausenstufe muss zwischen 0 und 2 liegen.") }
default:
break
}
return
}
if mode.needsFolder, mode != .admin, !(1...99).contains(Int(folder)) { throw SerialError.invalid("Ordner muss zwischen 01 und 99 liegen.") }
if mode == .single && special == 0 { throw SerialError.invalid("Bitte einen Track größer 0 auswählen.") }
if mode == .audiobookSingle && special >= 30 { throw SerialError.invalid("Hörbuch einzeln akzeptiert maximal 29 Zusatztracks.") }
if [.hoerspielRange, .albumRange, .partyRange, .audiobookRange].contains(mode), special == 0 || special > special2 {
throw SerialError.invalid("Der Trackbereich muss von 1 bis zum Endtrack reichen.")
}
if mode == .quiz && !([0, 2, 4].contains(Int(special)) && [0, 1].contains(Int(special2))) { throw SerialError.invalid("Ungültige Quiz-Optionen.") }
}
}
enum SerialError: LocalizedError {
case open(String, String), configure, disconnected, io(String), timeout, firmware(String), invalid(String)
var errorDescription: String? {
switch self {
case .open(let p, let e): "Serieller Port \(p) kann nicht geöffnet werden: \(e)"
case .configure: "Der serielle Port konnte nicht auf 115200 Baud konfiguriert werden."
case .disconnected: "Keine Verbindung zum TonUINO."
case .io(let e): "Serielle Kommunikation fehlgeschlagen: \(e)"
case .timeout: "Zeitüberschreitung. Prüfe, ob keine Karte auf dem TonUINO-Leser liegt und die Firmware SerialInputAsCommand aktiviert hat."
case .firmware(let text): text
case .invalid(let text): text
}
}
}
@@ -0,0 +1,133 @@
import Foundation
struct YouTubeChapter: Decodable, Identifiable, Sendable {
let start: Double
let end: Double?
let title: String?
var id: Int { Int(start * 1000) }
enum CodingKeys: String, CodingKey { case title; case start = "start_time"; case end = "end_time" }
}
struct YouTubeVideoInfo: Decodable, Sendable {
let id: String
let webpageURL: String?
let title: String
let uploader: String?
let channel: String?
let duration: Double?
let chapters: [YouTubeChapter]?
enum CodingKeys: String, CodingKey {
case id, title, uploader, channel, duration, chapters
case webpageURL = "webpage_url"
}
var artist: String { channel?.isEmpty == false ? channel! : (uploader ?? "") }
var usableChapters: [YouTubeChapter] {
guard let chapters else { return [] }
return chapters.filter { $0.start.isFinite && $0.start >= 0 && ($0.end ?? duration ?? 0) > $0.start }
}
}
struct PreparedYouTubeImport: Sendable {
let directory: URL
let files: [URL]
}
enum YouTubeImportService {
static func inspect(_ rawURL: String) throws -> YouTubeVideoInfo {
guard let url = validatedURL(rawURL) else { throw YouTubeImportError.invalidURL }
guard let ytDLP = MediaToolLocator.executable(named: "yt-dlp") else {
throw MediaToolError.missing("yt-dlp")
}
guard MediaToolLocator.executable(named: "deno") != nil else { throw MediaToolError.missing("Deno") }
let arguments = ["--dump-single-json", "--skip-download", "--no-playlist", "--no-warnings", "--quiet", "--no-cache-dir"]
+ MediaToolLocator.javascriptRuntimeArguments() + [url.absoluteString]
let data = try MediaProcess.run(ytDLP, arguments: arguments)
do { return try JSONDecoder().decode(YouTubeVideoInfo.self, from: data) }
catch { throw YouTubeImportError.invalidVideoInfo(error.localizedDescription) }
}
static func prepare(_ video: YouTubeVideoInfo, splitByChapters: Bool) throws -> PreparedYouTubeImport {
guard let sourceURL = validatedURL(video.webpageURL ?? "https://www.youtube.com/watch?v=\(video.id)") else { throw YouTubeImportError.invalidURL }
guard let ytDLP = MediaToolLocator.executable(named: "yt-dlp") else { throw MediaToolError.missing("yt-dlp") }
guard let ffmpeg = MediaToolLocator.executable(named: "ffmpeg") else { throw MediaToolError.missing("FFmpeg") }
guard MediaToolLocator.executable(named: "deno") != nil else { throw MediaToolError.missing("Deno") }
let chapters = video.usableChapters
guard !splitByChapters || !chapters.isEmpty else { throw YouTubeImportError.noChapters }
let directory = FileManager.default.temporaryDirectory.appendingPathComponent("tonuino-youtube-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: false)
do {
let outputTemplate = directory.appendingPathComponent("source.%(ext)s").path
let downloadArguments = ["--format", "bestaudio/best", "--output", outputTemplate, "--no-playlist", "--no-warnings", "--no-progress", "--quiet", "--no-cache-dir"]
+ MediaToolLocator.javascriptRuntimeArguments() + [sourceURL.absoluteString]
_ = try MediaProcess.run(ytDLP, arguments: downloadArguments)
let sourceFiles = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])
.filter { $0.lastPathComponent.hasPrefix("source.") && !$0.pathExtension.lowercased().hasSuffix("part") }
guard let audioURL = sourceFiles.first else { throw YouTubeImportError.downloadOutputMissing }
let outputs: [URL]
if splitByChapters {
outputs = try chapters.enumerated().map { index, chapter in
let end = chapter.end ?? video.duration ?? 0
let length = end - chapter.start
guard length > 0 else { throw YouTubeImportError.invalidChapter }
let output = directory.appendingPathComponent(String(format: "chapter-%03d.mp3", index + 1))
let displayTitle = chapter.title?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? "Kapitel \(index + 1)"
let args = [
"-nostdin", "-hide_banner", "-loglevel", "error", "-y",
"-ss", String(format: "%.3f", chapter.start), "-i", audioURL.path, "-t", String(format: "%.3f", length),
"-map", "0:a:0", "-vn", "-codec:a", "libmp3lame", "-q:a", "2", "-id3v2_version", "3",
"-metadata", "title=\(displayTitle)", "-metadata", "artist=\(video.artist)", "-metadata", "album=\(video.title)",
"-metadata", "track=\(index + 1)/\(chapters.count)", output.path
]
_ = try MediaProcess.run(ffmpeg, arguments: args)
return output
}
} else {
let output = directory.appendingPathComponent("audio.mp3")
let args = [
"-nostdin", "-hide_banner", "-loglevel", "error", "-y", "-i", audioURL.path,
"-map", "0:a:0", "-vn", "-codec:a", "libmp3lame", "-q:a", "2", "-id3v2_version", "3",
"-metadata", "title=\(video.title)", "-metadata", "artist=\(video.artist)", "-metadata", "album=\(video.title)", output.path
]
_ = try MediaProcess.run(ffmpeg, arguments: args)
outputs = [output]
}
return PreparedYouTubeImport(directory: directory, files: outputs)
} catch {
try? FileManager.default.removeItem(at: directory)
throw error
}
}
private static func validatedURL(_ raw: String) -> URL? {
guard let components = URLComponents(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)),
components.scheme?.lowercased() == "https",
let host = components.host?.lowercased(),
["youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com", "youtu.be", "www.youtube-nocookie.com"].contains(host),
let url = components.url else { return nil }
return url
}
}
private extension String {
var nonEmpty: String? { isEmpty ? nil : self }
}
enum YouTubeImportError: LocalizedError {
case invalidURL, invalidVideoInfo(String), noChapters, invalidChapter, downloadOutputMissing
var errorDescription: String? {
switch self {
case .invalidURL: "Bitte eine HTTPS-URL zu einem einzelnen YouTube-Video eingeben. Playlists werden nicht importiert."
case .invalidVideoInfo(let detail): "Videoinformationen konnten nicht gelesen werden: \(detail)"
case .noChapters: "Dieses Video enthält keine verwendbaren Kapitel. Importiere es als eine Datei."
case .invalidChapter: "Mindestens ein Kapitel hat eine ungültige Zeitspanne."
case .downloadOutputMissing: "yt-dlp hat keine Audiodatei erzeugt."
}
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>com.apple.security.smartcard</key><true/>
</dict></plist>
@@ -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: ", "))."
}
}
}
+282
View File
@@ -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)
}
}