Add chapters, history, bookmarks, live download progress, and i18n
- 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>
This commit is contained in:
@@ -9,8 +9,11 @@ struct SettingsView: View {
|
||||
@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]
|
||||
|
||||
@@ -20,6 +23,7 @@ struct SettingsView: View {
|
||||
Form {
|
||||
connectionSection
|
||||
playbackSection
|
||||
historySection
|
||||
appearanceSection
|
||||
downloadsSection
|
||||
aboutSection
|
||||
@@ -45,6 +49,24 @@ struct SettingsView: View {
|
||||
} 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 {
|
||||
@@ -54,6 +76,9 @@ struct SettingsView: View {
|
||||
playbackPane
|
||||
.tabItem { Label("Wiedergabe", systemImage: "play.circle") }
|
||||
|
||||
historyPane
|
||||
.tabItem { Label("Verlauf", systemImage: "clock.arrow.circlepath") }
|
||||
|
||||
appearancePane
|
||||
.tabItem { Label("Darstellung", systemImage: "square.grid.2x2") }
|
||||
|
||||
@@ -61,7 +86,7 @@ struct SettingsView: View {
|
||||
.tabItem { Label("Über", systemImage: "info.circle") }
|
||||
}
|
||||
.padding(20)
|
||||
.frame(width: 480, height: 320)
|
||||
.frame(width: 480, height: 360)
|
||||
.confirmationDialog(
|
||||
"Mit Server abmelden?",
|
||||
isPresented: $showLogoutConfirm,
|
||||
@@ -75,6 +100,19 @@ struct SettingsView: View {
|
||||
} 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
|
||||
}
|
||||
|
||||
@@ -130,6 +168,41 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -138,6 +211,10 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
@@ -219,6 +296,38 @@ struct SettingsView: View {
|
||||
.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) {
|
||||
@@ -227,6 +336,10 @@ struct SettingsView: View {
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -248,3 +361,15 @@ struct SettingsView: View {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user