Add Tonuino Manager website and Sparkle updates
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: ", "))."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||