- Chapter navigation with auto-scroll to current chapter and end-of-chapter sleep timer - Opt-in listening history (local-only) with XML export and per-item quick menu - Bookmarks with server sync via Audiobookshelf API - Live MB counter during downloads via URLSessionDownloadTask delegate - In-progress downloads shown in "Heruntergeladen" with dimmed cover + ring overlay - Cover image cache (50 MB memory / 500 MB disk URLCache) - German/English localization (de.lproj, en.lproj) - Loading spinner now triggers immediately on view switch instead of waiting for the network Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
376 lines
13 KiB
Swift
376 lines
13 KiB
Swift
import SwiftUI
|
|
|
|
struct SettingsView: View {
|
|
@Environment(AppState.self) private var app
|
|
#if os(iOS)
|
|
@Environment(\.dismiss) private var dismiss
|
|
#endif
|
|
|
|
@AppStorage("skipDurationSeconds") private var skipSeconds: Int = 30
|
|
@AppStorage("libraryLayout") private var layoutRaw: String = LibraryLayout.grid.rawValue
|
|
@AppStorage("autoRefreshOnLaunch") private var autoRefreshOnLaunch: Bool = true
|
|
@AppStorage("historyEnabled") private var historyEnabled: Bool = false
|
|
|
|
@State private var showLogoutConfirm: Bool = false
|
|
@State private var showHistoryDisableConfirm: Bool = false
|
|
@State private var showHistoryExport: Bool = false
|
|
|
|
private static let skipOptions: [Int] = [10, 15, 30, 45, 60, 90]
|
|
|
|
var body: some View {
|
|
#if os(iOS)
|
|
NavigationStack {
|
|
Form {
|
|
connectionSection
|
|
playbackSection
|
|
historySection
|
|
appearanceSection
|
|
downloadsSection
|
|
aboutSection
|
|
}
|
|
.navigationTitle("Einstellungen")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Button("Fertig") { dismiss() }
|
|
}
|
|
}
|
|
.confirmationDialog(
|
|
"Mit Server abmelden?",
|
|
isPresented: $showLogoutConfirm,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Abmelden", role: .destructive) {
|
|
app.stopPlayback()
|
|
app.auth.logout()
|
|
dismiss()
|
|
}
|
|
Button("Abbrechen", role: .cancel) { }
|
|
} message: {
|
|
Text("Du wirst zurück zur Login-Maske geschickt. Heruntergeladene Inhalte bleiben.")
|
|
}
|
|
.confirmationDialog(
|
|
"Hörverlauf deaktivieren?",
|
|
isPresented: $showHistoryDisableConfirm,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Verlauf löschen & deaktivieren", role: .destructive) {
|
|
app.history.clear()
|
|
historyEnabled = false
|
|
}
|
|
Button("Abbrechen", role: .cancel) { }
|
|
} message: {
|
|
Text("Der gesamte aufgezeichnete Hörverlauf wird unwiderruflich gelöscht.")
|
|
}
|
|
.sheet(isPresented: $showHistoryExport) {
|
|
if let url = app.history.exportXML() {
|
|
ShareSheet(url: url)
|
|
}
|
|
}
|
|
}
|
|
#else
|
|
TabView {
|
|
connectionPane
|
|
.tabItem { Label("Verbindung", systemImage: "server.rack") }
|
|
|
|
playbackPane
|
|
.tabItem { Label("Wiedergabe", systemImage: "play.circle") }
|
|
|
|
historyPane
|
|
.tabItem { Label("Verlauf", systemImage: "clock.arrow.circlepath") }
|
|
|
|
appearancePane
|
|
.tabItem { Label("Darstellung", systemImage: "square.grid.2x2") }
|
|
|
|
aboutPane
|
|
.tabItem { Label("Über", systemImage: "info.circle") }
|
|
}
|
|
.padding(20)
|
|
.frame(width: 480, height: 360)
|
|
.confirmationDialog(
|
|
"Mit Server abmelden?",
|
|
isPresented: $showLogoutConfirm,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Abmelden", role: .destructive) {
|
|
app.stopPlayback()
|
|
app.auth.logout()
|
|
}
|
|
Button("Abbrechen", role: .cancel) { }
|
|
} message: {
|
|
Text("Du wirst zur Login-Maske zurückgesetzt. Heruntergeladene Hörbücher bleiben erhalten.")
|
|
}
|
|
.confirmationDialog(
|
|
"Hörverlauf deaktivieren?",
|
|
isPresented: $showHistoryDisableConfirm,
|
|
titleVisibility: .visible
|
|
) {
|
|
Button("Verlauf löschen & deaktivieren", role: .destructive) {
|
|
app.history.clear()
|
|
historyEnabled = false
|
|
}
|
|
Button("Abbrechen", role: .cancel) { }
|
|
} message: {
|
|
Text("Der gesamte aufgezeichnete Hörverlauf wird unwiderruflich gelöscht.")
|
|
}
|
|
#endif
|
|
}
|
|
|
|
// MARK: - iOS Form sections
|
|
|
|
#if os(iOS)
|
|
private var connectionSection: some View {
|
|
Section {
|
|
LabeledContent("Server") {
|
|
Text(app.auth.serverURL.isEmpty ? "—" : app.auth.serverURL)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
}
|
|
LabeledContent("Benutzer") {
|
|
Text(app.auth.username.isEmpty ? "—" : app.auth.username)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
HStack(spacing: 8) {
|
|
Circle()
|
|
.fill(app.network.isOnline ? .green : .orange)
|
|
.frame(width: 8, height: 8)
|
|
Text(app.network.isOnline ? "Online" : "Offline")
|
|
if app.sync.queuedCount > 0 {
|
|
Text("\(app.sync.queuedCount) wartend")
|
|
.foregroundStyle(.secondary)
|
|
.font(.subheadline)
|
|
}
|
|
}
|
|
Button(role: .destructive) {
|
|
showLogoutConfirm = true
|
|
} label: {
|
|
Label("Abmelden / Server wechseln", systemImage: "rectangle.portrait.and.arrow.right")
|
|
}
|
|
} header: {
|
|
Text("Verbindung")
|
|
} footer: {
|
|
Text("Abmelden setzt die gespeicherten Anmeldedaten zurück. Heruntergeladene Inhalte bleiben.")
|
|
}
|
|
}
|
|
|
|
private var playbackSection: some View {
|
|
Section {
|
|
Picker("Sprung-Dauer", selection: $skipSeconds) {
|
|
ForEach(Self.skipOptions, id: \.self) { sec in
|
|
Text("\(sec) s").tag(sec)
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Wiedergabe")
|
|
} footer: {
|
|
Text("Gilt für die Skip-Knöpfe in der Player-Leiste und auf dem Sperrbildschirm.")
|
|
}
|
|
}
|
|
|
|
private var historySection: some View {
|
|
Section {
|
|
Toggle(isOn: Binding(
|
|
get: { historyEnabled },
|
|
set: { newVal in
|
|
if !newVal && historyEnabled {
|
|
showHistoryDisableConfirm = true
|
|
} else {
|
|
historyEnabled = newVal
|
|
}
|
|
}
|
|
)) {
|
|
Label("Hörverlauf aktivieren", systemImage: "clock.arrow.circlepath")
|
|
}
|
|
if historyEnabled {
|
|
LabeledContent("Einträge") {
|
|
Text("\(app.history.entries.count)")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Button {
|
|
showHistoryExport = true
|
|
} label: {
|
|
Label("Verlauf als XML exportieren", systemImage: "square.and.arrow.up")
|
|
}
|
|
.disabled(app.history.entries.isEmpty)
|
|
}
|
|
} header: {
|
|
Text("Hörverlauf")
|
|
} footer: {
|
|
Text(historyEnabled
|
|
? "Positionen werden vor jedem Sprung aufgezeichnet (max. 200 Einträge). Daten verbleiben lokal auf diesem Gerät."
|
|
: "Protokolliert, wo du vor einem Sprung warst, damit du zurücknavigieren kannst. Standardmäßig deaktiviert.")
|
|
}
|
|
}
|
|
|
|
private var appearanceSection: some View {
|
|
Section {
|
|
Picker("Bibliotheks-Ansicht", selection: $layoutRaw) {
|
|
ForEach(LibraryLayout.allCases) { l in
|
|
Label(l.label, systemImage: l.systemImage).tag(l.rawValue)
|
|
}
|
|
}
|
|
Toggle("Beim Start automatisch aktualisieren", isOn: $autoRefreshOnLaunch)
|
|
Picker("Sprache", selection: Binding(get: { app.language }, set: { app.language = $0 })) {
|
|
Text("Deutsch").tag("de")
|
|
Text("English").tag("en")
|
|
}
|
|
} header: {
|
|
Text("Darstellung")
|
|
}
|
|
}
|
|
|
|
private var downloadsSection: some View {
|
|
Section {
|
|
LabeledContent("Heruntergeladen") {
|
|
Text("\(app.downloads.downloadedItems.count) Einträge")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
} header: {
|
|
Text("Downloads")
|
|
} footer: {
|
|
Text("Heruntergeladene Hörbücher und Folgen können einzeln über das Kontextmenü gelöscht werden.")
|
|
}
|
|
}
|
|
|
|
private var aboutSection: some View {
|
|
Section {
|
|
LabeledContent("Version") {
|
|
Text(appVersion).foregroundStyle(.secondary)
|
|
}
|
|
} header: {
|
|
Text("Über")
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// MARK: - macOS TabView panes
|
|
|
|
#if os(macOS)
|
|
private var connectionPane: some View {
|
|
Form {
|
|
LabeledContent("Server") {
|
|
Text(app.auth.serverURL.isEmpty ? "—" : app.auth.serverURL)
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
}
|
|
LabeledContent("Benutzer") {
|
|
Text(app.auth.username.isEmpty ? "—" : app.auth.username)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
LabeledContent("Status") {
|
|
HStack(spacing: 6) {
|
|
Circle()
|
|
.fill(app.network.isOnline ? .green : .orange)
|
|
.frame(width: 8, height: 8)
|
|
Text(app.network.isOnline ? "Online" : "Offline")
|
|
if app.sync.queuedCount > 0 {
|
|
Text("(\(app.sync.queuedCount) wartend)")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
HStack {
|
|
Spacer()
|
|
Button(role: .destructive) {
|
|
showLogoutConfirm = true
|
|
} label: {
|
|
Label("Abmelden / Server wechseln", systemImage: "rectangle.portrait.and.arrow.right")
|
|
}
|
|
}
|
|
}
|
|
.formStyle(.grouped)
|
|
}
|
|
|
|
private var playbackPane: some View {
|
|
Form {
|
|
Picker("Sprung-Dauer", selection: $skipSeconds) {
|
|
ForEach(Self.skipOptions, id: \.self) { sec in
|
|
Text("\(sec) s").tag(sec)
|
|
}
|
|
}
|
|
Text("Gilt für die Skip-Knöpfe in der Player-Leiste und Medientasten.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.formStyle(.grouped)
|
|
}
|
|
|
|
private var historyPane: some View {
|
|
Form {
|
|
Toggle(isOn: Binding(
|
|
get: { historyEnabled },
|
|
set: { newVal in
|
|
if !newVal && historyEnabled { showHistoryDisableConfirm = true }
|
|
else { historyEnabled = newVal }
|
|
}
|
|
)) {
|
|
Text("Hörverlauf aktivieren")
|
|
}
|
|
if historyEnabled {
|
|
LabeledContent("Einträge", value: "\(app.history.entries.count)")
|
|
HStack {
|
|
Spacer()
|
|
Button("Verlauf als XML exportieren") {
|
|
if let url = app.history.exportXML() {
|
|
NSWorkspace.shared.activateFileViewerSelecting([url])
|
|
}
|
|
}
|
|
.disabled(app.history.entries.isEmpty)
|
|
}
|
|
}
|
|
Text(historyEnabled
|
|
? "Positionen werden vor jedem Sprung aufgezeichnet (max. 200 Einträge)."
|
|
: "Protokolliert Positionen vor Sprüngen, damit du zurücknavigieren kannst.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.formStyle(.grouped)
|
|
}
|
|
|
|
private var appearancePane: some View {
|
|
Form {
|
|
Picker("Bibliotheks-Ansicht", selection: $layoutRaw) {
|
|
ForEach(LibraryLayout.allCases) { l in
|
|
Label(l.label, systemImage: l.systemImage).tag(l.rawValue)
|
|
}
|
|
}
|
|
Toggle("Beim Start automatisch aktualisieren", isOn: $autoRefreshOnLaunch)
|
|
Picker("Sprache", selection: Binding(get: { app.language }, set: { app.language = $0 })) {
|
|
Text("Deutsch").tag("de")
|
|
Text("English").tag("en")
|
|
}
|
|
}
|
|
.formStyle(.grouped)
|
|
}
|
|
|
|
private var aboutPane: some View {
|
|
Form {
|
|
LabeledContent("Version", value: appVersion)
|
|
LabeledContent("Heruntergeladen", value: "\(app.downloads.downloadedItems.count) Einträge")
|
|
}
|
|
.formStyle(.grouped)
|
|
}
|
|
#endif
|
|
|
|
// MARK: - Shared
|
|
|
|
private var appVersion: String {
|
|
let v = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?"
|
|
let b = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?"
|
|
return "\(v) (\(b))"
|
|
}
|
|
}
|
|
|
|
#if os(iOS)
|
|
import UIKit
|
|
|
|
struct ShareSheet: UIViewControllerRepresentable {
|
|
let url: URL
|
|
func makeUIViewController(context: Context) -> UIActivityViewController {
|
|
UIActivityViewController(activityItems: [url], applicationActivities: nil)
|
|
}
|
|
func updateUIViewController(_ uvc: UIActivityViewController, context: Context) {}
|
|
}
|
|
#endif
|